Compare commits
1 Commits
main
...
scroll-wal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ded0bef6 |
23
.github/workflows/Xcode-build-analyze.yml
vendored
23
.github/workflows/Xcode-build-analyze.yml
vendored
@ -14,28 +14,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Pin Xcode version
|
||||
run: |
|
||||
XCODE_VERSION=$(cat .xcode-version)
|
||||
sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
|
||||
xcodebuild -version
|
||||
|
||||
- name: Resolve packages
|
||||
run: xcodebuild -resolvePackageDependencies -project birch.xcodeproj -scheme birch
|
||||
|
||||
- name: Validate Package.resolved
|
||||
run: |
|
||||
if ! git diff --quiet birch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; then
|
||||
echo "::error::Package.resolved has uncommitted changes after resolution. Commit the updated Package.resolved."
|
||||
git diff birch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
scheme: birch
|
||||
file_to_build: birch.xcodeproj
|
||||
scheme: hellbender
|
||||
file_to_build: hellbender.xcodeproj
|
||||
filetype_parameter: project
|
||||
run: |
|
||||
xcodebuild clean build analyze -scheme "$scheme" -"$filetype_parameter" "$file_to_build" CODE_SIGNING_ALLOWED=NO | xcpretty && exit ${PIPESTATUS[0]}
|
||||
|
||||
2
.github/workflows/Xcode-unit-tests.yml
vendored
2
.github/workflows/Xcode-unit-tests.yml
vendored
@ -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]}
|
||||
|
||||
63
.github/workflows/reproducible-build-check.yml
vendored
63
.github/workflows/reproducible-build-check.yml
vendored
@ -1,63 +0,0 @@
|
||||
name: Reproducible Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
reproducibility:
|
||||
name: Verify build reproducibility
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Pin Xcode version
|
||||
run: |
|
||||
XCODE_VERSION=$(cat .xcode-version)
|
||||
sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer"
|
||||
xcodebuild -version
|
||||
|
||||
- name: Build 1
|
||||
run: |
|
||||
DERIVED_DATA="/tmp/birch-build-1"
|
||||
rm -rf "$DERIVED_DATA"
|
||||
xcodebuild archive \
|
||||
-scheme birch \
|
||||
-project birch.xcodeproj \
|
||||
-archivePath "$DERIVED_DATA/birch.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-configuration Release \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
| xcpretty && exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Build 2
|
||||
run: |
|
||||
DERIVED_DATA="/tmp/birch-build-2"
|
||||
rm -rf "$DERIVED_DATA"
|
||||
xcodebuild archive \
|
||||
-scheme birch \
|
||||
-project birch.xcodeproj \
|
||||
-archivePath "$DERIVED_DATA/birch.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-configuration Release \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
| xcpretty && exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Normalize both builds
|
||||
run: |
|
||||
APP1="/tmp/birch-build-1/birch.xcarchive/Products/Applications/birch.app"
|
||||
APP2="/tmp/birch-build-2/birch.xcarchive/Products/Applications/birch.app"
|
||||
./scripts/normalize-app.sh "$APP1"
|
||||
./scripts/normalize-app.sh "$APP2"
|
||||
|
||||
- name: Compare builds
|
||||
run: |
|
||||
APP1="/tmp/birch-build-1/birch.xcarchive/Products/Applications/birch.app"
|
||||
APP2="/tmp/birch-build-2/birch.xcarchive/Products/Applications/birch.app"
|
||||
./scripts/compare-builds.sh "$APP1" "$APP2"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -1 +0,0 @@
|
||||
26.4
|
||||
@ -1,61 +0,0 @@
|
||||
// Base.xcconfig — Shared build settings for all configurations
|
||||
|
||||
ALWAYS_SEARCH_USER_PATHS = NO
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES
|
||||
|
||||
// Static analysis
|
||||
CLANG_ANALYZER_NONNULL = YES
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE
|
||||
|
||||
// Language standards
|
||||
CLANG_CXX_LANGUAGE_STANDARD = gnu++20
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17
|
||||
|
||||
// Modules and ARC
|
||||
CLANG_ENABLE_MODULES = YES
|
||||
CLANG_ENABLE_OBJC_ARC = YES
|
||||
CLANG_ENABLE_OBJC_WEAK = YES
|
||||
|
||||
// Warnings — Clang
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES
|
||||
CLANG_WARN_BOOL_CONVERSION = YES
|
||||
CLANG_WARN_COMMA = YES
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES
|
||||
CLANG_WARN_EMPTY_BODY = YES
|
||||
CLANG_WARN_ENUM_CONVERSION = YES
|
||||
CLANG_WARN_INFINITE_RECURSION = YES
|
||||
CLANG_WARN_INT_CONVERSION = YES
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
|
||||
|
||||
// Warnings — GCC
|
||||
GCC_NO_COMMON_BLOCKS = YES
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE
|
||||
GCC_WARN_UNUSED_FUNCTION = YES
|
||||
GCC_WARN_UNUSED_VARIABLE = YES
|
||||
|
||||
// Build settings
|
||||
COPY_PHASE_STRIP = NO
|
||||
DEVELOPMENT_TEAM = ZW85AH743B
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES
|
||||
MTL_FAST_MATH = YES
|
||||
SDKROOT = iphoneos
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES
|
||||
@ -1,19 +0,0 @@
|
||||
// Birch.xcconfig — Target-level settings for the birch app target
|
||||
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
|
||||
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIcon-Dark
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
CURRENT_PROJECT_VERSION = 23
|
||||
DEVELOPMENT_ASSET_PATHS = "birch/Preview Content"
|
||||
GENERATE_INFOPLIST_FILE = NO
|
||||
INFOPLIST_FILE = birch/Info.plist
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
|
||||
MARKETING_VERSION = 0.1.2
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender
|
||||
PRODUCT_NAME = $(TARGET_NAME)
|
||||
SWIFT_EMIT_LOC_STRINGS = YES
|
||||
SWIFT_VERSION = 5.0
|
||||
TARGETED_DEVICE_FAMILY = 1,2
|
||||
@ -1,13 +0,0 @@
|
||||
// Debug.xcconfig — Debug-specific build settings
|
||||
#include "Base.xcconfig"
|
||||
|
||||
DEBUG_INFORMATION_FORMAT = dwarf
|
||||
ENABLE_PREVIEWS = YES
|
||||
ENABLE_TESTABILITY = YES
|
||||
GCC_DYNAMIC_NO_PIC = NO
|
||||
GCC_OPTIMIZATION_LEVEL = 0
|
||||
GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited)
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE
|
||||
ONLY_ACTIVE_ARCH = YES
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited)
|
||||
SWIFT_OPTIMIZATION_LEVEL = -Onone
|
||||
@ -1,13 +0,0 @@
|
||||
// Release.xcconfig — Release-specific build settings + reproducibility
|
||||
#include "Base.xcconfig"
|
||||
|
||||
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
|
||||
ENABLE_NS_ASSERTIONS = NO
|
||||
ENABLE_PREVIEWS = NO
|
||||
MTL_ENABLE_DEBUG_INFO = NO
|
||||
SWIFT_COMPILATION_MODE = wholemodule
|
||||
VALIDATE_PRODUCT = YES
|
||||
|
||||
// Reproducibility: remap absolute paths in debug info
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -debug-prefix-map $(SRCROOT)=. -debug-prefix-map $(HOME)=~
|
||||
OTHER_CFLAGS = $(inherited) -fdebug-prefix-map=$(SRCROOT)=. -fdebug-prefix-map=$(HOME)=~
|
||||
338
Gemfile.lock
338
Gemfile.lock
@ -1,338 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1237.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.219.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.98.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.3)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.20.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-darwin-24
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1237.0) sha256=9b82f529b69ad83a8e4c5e123038924ed5e8f59bd6064a293ef20efc63364841
|
||||
aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea
|
||||
aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20
|
||||
aws-sdk-s3 (1.219.0) sha256=6a755d7377978525758b3c29185ca6a10128ce2b07555ca37c4549de10c2f1c7
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
|
||||
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
|
||||
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
|
||||
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
|
||||
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
|
||||
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
|
||||
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
|
||||
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
|
||||
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
|
||||
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
|
||||
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.98.0) sha256=094fb952419c1131c16c4dfa66e0c96e6a2fa33adbe266f614b84b22cbc8c5cb
|
||||
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
|
||||
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
|
||||
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
|
||||
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
|
||||
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
|
||||
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
|
||||
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
|
||||
google-cloud-storage (1.59.0) sha256=b8c9a5661d775d65ccb279bb1d6be07fd8152576eb0146c2026bd023c4b186b9
|
||||
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
|
||||
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
|
||||
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.20.0) sha256=c64106fae5114bd7f388d42d7b52ebb83d7726426d47a35ad5099e35bb923e41
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
||||
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
|
||||
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.10
|
||||
159
README.md
159
README.md
@ -1,29 +1,24 @@
|
||||
<p align="center">
|
||||
<img src="https://birchwallet.app/assets/AppIcon-og.png" alt="Birch" width="128" height="128" style="border-radius: 24px;" />
|
||||
<img src="https://hellbenderwallet.com/assets/AppIcon-og.png" alt="Hellbender" width="128" height="128" style="border-radius: 24px;" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Birch</h1>
|
||||
<h1 align="center">Hellbender</h1>
|
||||
|
||||
<p align="center">
|
||||
<em>Travel to your private keys and leave your laptop at home.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://birchwallet.app/assets/screenshots/welcome.png" alt="Welcome" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/wallet-setup.png" alt="Setup Choice" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/multisig-config.png" alt="New Wallet Multisig Config" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/cosigner-import.png" alt="Cosigner Import" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/verify-wallet-top.png" alt="Verify Wallet" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/verify-wallet-backup.png" alt="Backup PDF/QR" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/transactions.png" alt="Transactions" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/send.png" alt="Send" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/receive.png" alt="Receive" width="150" />
|
||||
<img src="https://birchwallet.app/assets/screenshots/utxos.png" alt="UTXO" width="150" />
|
||||
<img src="https://hellbenderwallet.com/assets/screenshots/welcome.png" alt="Welcome" width="150" />
|
||||
<img src="https://hellbenderwallet.com/assets/screenshots/transactions.png" alt="Transactions" width="150" />
|
||||
<img src="https://hellbenderwallet.com/assets/screenshots/multisig-config.png" alt="Multisig Config" width="150" />
|
||||
<img src="https://hellbenderwallet.com/assets/screenshots/import-descriptor.png" alt="Import Descriptor" width="150" />
|
||||
<img src="https://hellbenderwallet.com/assets/screenshots/review-wallet.png" alt="Review Wallet" width="150" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Birch is an iOS Bitcoin multisig coordinator written in Swift. It operates as a **watch-only wallet** — private keys never touch your phone. Coordinate signing across air-gapped hardware wallets using animated QR codes, bringing cold storage security with mobile convenience.
|
||||
Hellbender is an iOS Bitcoin multisig coordinator written in Swift. It operates as a **watch-only wallet** — private keys never touch your phone. Coordinate signing across air-gapped hardware wallets using animated QR codes, bringing cold storage security with mobile convenience.
|
||||
|
||||
## Features
|
||||
|
||||
@ -43,7 +38,7 @@ Birch is an iOS Bitcoin multisig coordinator written in Swift. It operates as a
|
||||
|
||||
### Requirements
|
||||
|
||||
- Xcode 26.2+
|
||||
- Xcode 16.2+
|
||||
- iOS 18.6+ deployment target
|
||||
- Swift 5.0
|
||||
|
||||
@ -65,145 +60,17 @@ All dependencies are managed via Swift Package Manager and resolve automatically
|
||||
git clone https://github.com/newtonick/hellbender-wallet.git
|
||||
cd hellbender-wallet
|
||||
```
|
||||
2. Open `birch.xcodeproj` in Xcode
|
||||
2. Open `hellbender.xcodeproj` in Xcode
|
||||
3. SPM dependencies resolve automatically on first open
|
||||
4. Build and run on a simulator or device
|
||||
|
||||
For reproducible release builds, see [Reproducible Builds](#reproducible-builds) below.
|
||||
|
||||
### CI
|
||||
|
||||
GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`. A separate [reproducibility verification workflow](.github/workflows/reproducible-build-check.yml) builds the project twice, normalizes both outputs, and compares them to catch non-determinism regressions.
|
||||
|
||||
### Reproducible Builds
|
||||
|
||||
Birch supports **functionally equivalent** reproducible builds. Given the same source code and Xcode version, two independent builds will produce the same compiled logic after normalization. Certain metadata bytes (Mach-O UUIDs, timestamps, build-machine identifiers) are expected to differ and are zeroed by the normalization step.
|
||||
|
||||
**What IS reproducible** (after normalization): all code-bearing sections, resources, and application logic.
|
||||
|
||||
**What is NOT reproducible**: code signing timestamps, Mach-O LC_UUID values, Xcode build-machine metadata, App Store .ipa files (Apple re-signs and applies FairPlay DRM).
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Exact Xcode version matching `.xcode-version` (currently 26.4)
|
||||
- macOS with the matching SDK
|
||||
|
||||
#### Producing a verifiable build
|
||||
|
||||
```bash
|
||||
./scripts/build-release.sh
|
||||
```
|
||||
|
||||
This creates an unsigned archive at `/tmp/birch-build/birch.xcarchive`.
|
||||
|
||||
#### Verifying two builds
|
||||
|
||||
```bash
|
||||
# Normalize both builds
|
||||
./scripts/normalize-app.sh /path/to/build1.app
|
||||
./scripts/normalize-app.sh /path/to/build2.app
|
||||
|
||||
# Compare
|
||||
./scripts/compare-builds.sh /path/to/build1.app /path/to/build2.app
|
||||
```
|
||||
|
||||
The comparison exits 0 if the builds are functionally equivalent, 1 if code differences are found.
|
||||
|
||||
## Generating Screenshots
|
||||
|
||||
Birch uses [`fastlane snapshot`](https://docs.fastlane.tools/actions/snapshot/) to generate marketing and App Store screenshots. A single UI test walks the app from Welcome through the main tabs, capturing every major screen on each configured device in both dark and light mode.
|
||||
|
||||
### One-time setup
|
||||
|
||||
1. Install [Bundler](https://bundler.io/) if you don't already have it:
|
||||
```bash
|
||||
gem install bundler
|
||||
```
|
||||
2. Install fastlane via the project `Gemfile`:
|
||||
```bash
|
||||
bundle install
|
||||
```
|
||||
3. Install [ImageMagick](https://imagemagick.org/) — used to composite iPhone 13 mini screenshots onto their bezel (frameit's bundled 13 mini frame has a pixel-misalignment bug that leaves a visible gap, so we bypass frameit for that device):
|
||||
```bash
|
||||
brew install imagemagick
|
||||
```
|
||||
4. Patch the installed fastlane gem with iPhone 16/17 device support (from
|
||||
[fastlane PR #29921](https://github.com/fastlane/fastlane/pull/29921)):
|
||||
```bash
|
||||
bundle exec ruby scripts/patch-frameit.rb
|
||||
```
|
||||
This is idempotent — safe to re-run. If you upgrade fastlane, the script
|
||||
aborts with a clear error so you can review the patch for the new version.
|
||||
|
||||
On a fresh machine, also download the device frame PNGs before running
|
||||
the patch (they live at `~/.fastlane/frameit/latest/` and are not in the repo):
|
||||
```bash
|
||||
bundle exec fastlane frameit download_frames
|
||||
```
|
||||
|
||||
5. Make sure the required simulators are downloaded. The screenshot lane targets:
|
||||
- **iPhone 17 Pro Max** (6.9")
|
||||
- **iPhone 17 Pro** (6.3")
|
||||
- **iPhone 11 Pro Max** (6.5")
|
||||
- **iPhone 13 mini** (5.4")
|
||||
|
||||
You can trigger a download by booting them once in Xcode (**Window → Devices and Simulators → Simulators → +**) or via the command line:
|
||||
```bash
|
||||
xcrun simctl list devices | grep -E "iPhone 17 Pro Max|iPhone 17 Pro|iPhone 11 Pro Max|iPhone 13 mini"
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
bundle exec fastlane screenshots
|
||||
```
|
||||
|
||||
This runs the `screenshots` lane defined in [`fastlane/Fastfile`](fastlane/Fastfile), which:
|
||||
|
||||
1. Captures all 23 stops in **dark mode** first (the product's default aesthetic)
|
||||
2. Captures the same stops in **light mode**
|
||||
3. Moves iPhone 13 mini bare captures aside (frameit's bundled 13 mini frame has a pixel-misalignment bug)
|
||||
4. Runs `frameit` to composite device bezels onto iPhone 17 Pro Max, iPhone 17 Pro, and iPhone 11 Pro Max screenshots
|
||||
5. Moves every `*_framed.png` into a sibling `framed/` subfolder
|
||||
6. Composites iPhone 13 mini captures onto the bezel directly with ImageMagick (upscaling to 1086×2353 so the screenshot fully covers the frame's screen hole) and writes the result into the same `framed/` directory
|
||||
|
||||
Output lands in:
|
||||
|
||||
```
|
||||
fastlane/screenshots/
|
||||
├── dark/
|
||||
│ ├── en-US/
|
||||
│ │ ├── iPhone 17 Pro Max-01-Welcome.png
|
||||
│ │ ├── iPhone 17 Pro-01-Welcome.png
|
||||
│ │ └── ...
|
||||
│ └── framed/
|
||||
│ ├── iPhone 17 Pro Max-01-Welcome_framed.png
|
||||
│ ├── iPhone 17 Pro-01-Welcome_framed.png
|
||||
│ └── ...
|
||||
└── light/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
- [`birchUITests/ScreenshotTests.swift`](birchUITests/ScreenshotTests.swift) is a dedicated XCUITest that walks the app. It reuses the existing `-UITesting` launch argument (defined in `birch/birchApp.swift`), which wipes `UserDefaults`/keychain and uses an in-memory SwiftData store so every run starts from a deterministic Welcome screen.
|
||||
- The test imports a real testnet4 1-of-2 `wsh(sortedmulti(...))` descriptor with live history, waits for Electrum sync, then visits each screen.
|
||||
- Dark/light mode is driven by the simulator's OS appearance (`xcrun simctl ui ... appearance`). The app's `RootView` follows the OS when the theme is set to `.system`, which it is by default after the `-UITesting` wipe, so no app-side toggle is required.
|
||||
- The device matrix, scheme, status bar override, and other `snapshot` options live in [`fastlane/Snapfile`](fastlane/Snapfile). Device destinations (simulator OS version), the frameit pass, and the custom 13 mini ImageMagick composite all live in [`fastlane/Fastfile`](fastlane/Fastfile).
|
||||
|
||||
### Customizing
|
||||
|
||||
- **Add/remove devices:** edit both the `devices([...])` array in `fastlane/Snapfile` and the `DEVICES` hash in `fastlane/Fastfile`.
|
||||
- **Change which screens are captured:** edit `testScreenshotTour` in `birchUITests/ScreenshotTests.swift` and add or remove `snapshot("NN-Name")` calls.
|
||||
- **Skip framing:** remove the `frameit(...)` lines and the ImageMagick composite block (steps 4–7) from `fastlane/Fastfile` if you only need the bare PNGs.
|
||||
|
||||
> **Known workaround** (contained in `fastlane/Fastfile`): `frameit` gem 2.232.2's bundled iPhone 13 Mini frame PNG has a ~3-pixel placement-offset bug that leaves a visible edge gap, so 13 mini is composited directly with ImageMagick instead. iPhone 16/17 device support is patched in via `scripts/patch-frameit.rb` (see setup step 4 above).
|
||||
GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`.
|
||||
|
||||
## Links
|
||||
|
||||
- **Website**: [birchwallet.app](https://birchwallet.app)
|
||||
- **Website**: [hellbenderwallet.com](https://hellbenderwallet.com)
|
||||
- **TestFlight Beta**: [Join the beta](https://testflight.apple.com/join/PuHVwJDJ)
|
||||
- **Author**: [newtonick](https://github.com/newtonick/hellbender-wallet/)
|
||||
|
||||
@ -211,5 +78,5 @@ fastlane/screenshots/
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
Birch's dependencies use permissive licenses compatible with MIT:
|
||||
Hellbender's dependencies use permissive licenses compatible with MIT:
|
||||
bdk-swift (MIT/Apache-2.0), URKit (BSD-2-Clause-Patent), URUI (BSD-2-Clause-Patent), Bbqr (Apache-2.0).
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
@ -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 |
@ -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 |
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Birch Wallet</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundlePrimaryIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
<key>CFBundleAlternateIcons</key>
|
||||
<dict>
|
||||
<key>AppIcon-Dark</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon-Dark</string>
|
||||
</array>
|
||||
<key>UIPrerenderedIcon</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleIcons~ipad</key>
|
||||
<dict>
|
||||
<key>CFBundlePrimaryIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
<key>CFBundleAlternateIcons</key>
|
||||
<dict>
|
||||
<key>AppIcon-Dark</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon-Dark</string>
|
||||
</array>
|
||||
<key>UIPrerenderedIcon</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Birch needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Birch uses Face ID to securely unlock your wallet.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,352 +0,0 @@
|
||||
import SwiftUI
|
||||
import URKit
|
||||
|
||||
struct WalletVerifyView: View {
|
||||
@Bindable var viewModel: SetupWizardViewModel
|
||||
let onComplete: () -> Void
|
||||
@State private var showDescriptorQR = false
|
||||
@State private var showDescriptorPDF = false
|
||||
@State private var copiedDescriptor = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Text("Verify Wallet")
|
||||
.font(.hbDisplay(28))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
// MARK: - Wallet Summary
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ReviewRow(label: "Name", value: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName)
|
||||
ReviewRow(label: "Type", value: "\(viewModel.requiredSignatures)-of-\(viewModel.totalCosigners) Multisig")
|
||||
ReviewRow(label: "Network", value: viewModel.network.displayName)
|
||||
ReviewRow(label: "Script", value: "P2WSH (Native Segwit)")
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Cosigners
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Cosigners")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
ForEach(0 ..< viewModel.totalCosigners, id: \.self) { index in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(viewModel.cosignerLabels[index])
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("FP:")
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
Text(viewModel.cosignerFingerprints[index])
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
|
||||
Text(viewModel.cosignerXpubs[index])
|
||||
.font(.hbMono(10))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.hbSurfaceElevated)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Back Up Your Descriptor
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
.font(.system(size: 20))
|
||||
Text("Back Up Your Descriptor")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
}
|
||||
|
||||
Text("The output descriptor is your **only** recovery path. If you lose Birch (phone dies, app deleted, data corrupted), the descriptor is the only thing needed to rebuild the wallet in any compatible coordinator (Sparrow, Nunchuk, etc.). Without it, you'd need to re-gather all cosigner xpubs and reconstruct the exact same configuration — which may not be possible.")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
BulletRow(text: "Print to PDF")
|
||||
BulletRow(text: "Import into Sparrow Wallet on another computer")
|
||||
BulletRow(text: "Save to an encrypted drive")
|
||||
}
|
||||
|
||||
Button(action: { showDescriptorPDF = true }) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.richtext")
|
||||
Text("PDF/Print Output Descriptor")
|
||||
.font(.hbBody(15))
|
||||
}
|
||||
.foregroundStyle(Color.purple)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.purple.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Button(action: { showDescriptorQR = true }) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
Text("Show Descriptor QR")
|
||||
.font(.hbBody(15))
|
||||
}
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.hbBitcoinOrange.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = viewModel.combinedDescriptor
|
||||
copiedDescriptor = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
copiedDescriptor = false
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: copiedDescriptor ? "checkmark" : "doc.on.doc")
|
||||
Text(copiedDescriptor ? "Copied!" : "Copy Descriptor")
|
||||
.font(.hbBody(15))
|
||||
}
|
||||
.foregroundStyle(Color.hbSteelBlue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.hbSteelBlue.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Verify Receive Address
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Verify Receive Address", systemImage: "checkmark.shield")
|
||||
.font(.hbHeadline)
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
|
||||
Text("Verifying your first receive address confirms that Birch built the correct output descriptor and will generate the same addresses as your cosigner devices. If the addresses don't match, funds sent to this wallet could be unspendable.")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
if let error = viewModel.addressDerivationError {
|
||||
Text(error)
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbError)
|
||||
|
||||
Button(action: { viewModel.deriveFirstAddress() }) {
|
||||
Text("Retry")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
} else if !viewModel.firstReceiveAddress.isEmpty {
|
||||
QRCodeView(content: viewModel.firstReceiveAddress)
|
||||
.frame(width: 200, height: 200)
|
||||
.padding(12)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
viewModel.firstReceiveAddress.chunkedAddressText(font: .hbMono(12))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.textSelection(.enabled)
|
||||
|
||||
Text("Index 0")
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Text("Load the output descriptor into your hardware signer (SeedSigner, Krux, etc.) and verify this address matches the first receive address shown on the device.")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
.hbCard()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
Button(action: onComplete) {
|
||||
Text("Create Wallet")
|
||||
.hbPrimaryButton()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Button(action: { viewModel.goBack() }) {
|
||||
Text("Back")
|
||||
.font(.hbBody(16))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.sheet(isPresented: $showDescriptorQR) {
|
||||
DescriptorQRSheet(descriptor: viewModel.combinedDescriptor, walletName: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName)
|
||||
}
|
||||
.sheet(isPresented: $showDescriptorPDF) {
|
||||
DescriptorPDFView(
|
||||
walletName: viewModel.walletName.isEmpty ? "My Wallet" : viewModel.walletName,
|
||||
descriptor: viewModel.externalDescriptor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
private struct ReviewRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.hbBody(15))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DescriptorQRSheet: View {
|
||||
let descriptor: String
|
||||
let walletName: String
|
||||
let descriptorUR: UR?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isExporting = false
|
||||
@AppStorage(Constants.qrFrameRateKey) private var qrFrameRate: Double = 4.0
|
||||
|
||||
init(descriptor: String, walletName: String) {
|
||||
self.descriptor = descriptor
|
||||
self.walletName = walletName
|
||||
descriptorUR = try? URService.encodeCryptoOutput(descriptor: descriptor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.hbBackground.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
if let ur = descriptorUR {
|
||||
URDisplaySheet(ur: ur)
|
||||
.padding(5)
|
||||
.background(Color.white)
|
||||
.shadow(color: Color.hbBitcoinOrange.opacity(0.2), radius: 20)
|
||||
} else {
|
||||
Text("Failed to encode descriptor")
|
||||
.font(.hbBody())
|
||||
.foregroundStyle(Color.hbError)
|
||||
}
|
||||
|
||||
Text("Scan to import this wallet descriptor")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button(action: { exportAsMP4() }) {
|
||||
if isExporting {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(Color.hbSteelBlue)
|
||||
Text("Generating video...")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbSteelBlue)
|
||||
}
|
||||
} else {
|
||||
Label("Export Descriptor as MP4", systemImage: "film")
|
||||
.font(.hbBody(14))
|
||||
.foregroundStyle(Color.hbSteelBlue)
|
||||
}
|
||||
}
|
||||
.disabled(isExporting || descriptorUR == nil)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("Wallet Descriptor")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportAsMP4() {
|
||||
guard let ur = descriptorUR else { return }
|
||||
isExporting = true
|
||||
Task {
|
||||
do {
|
||||
let url = try await QRVideoExporter.exportMP4(
|
||||
ur: ur,
|
||||
fileName: "\(walletName) Output Descriptor",
|
||||
maxFragmentLen: 160,
|
||||
fps: qrFrameRate,
|
||||
loopCount: 3
|
||||
)
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
presentShareSheet(url: url)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShareSheet(url: URL) {
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [url],
|
||||
applicationActivities: nil
|
||||
)
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
var topVC = windowScene.windows.first?.rootViewController else { return }
|
||||
while let presented = topVC.presentedViewController {
|
||||
topVC = presented
|
||||
}
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = topVC.view
|
||||
popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: 0, width: 0, height: 0)
|
||||
}
|
||||
topVC.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BulletRow: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\u{2022}")
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbBitcoinOrange)
|
||||
Text(text)
|
||||
.font(.hbBody(13))
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 == "")
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
@ -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
|
||||
@ -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")
|
||||
@ -20,54 +20,37 @@
|
||||
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
|
||||
remoteInfo = birch;
|
||||
remoteInfo = hellbender;
|
||||
};
|
||||
3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 3C9ACE1C2F5DED94009B00D0 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 3C9ACE232F5DED94009B00D0;
|
||||
remoteInfo = birch;
|
||||
remoteInfo = hellbender;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3C9ACE242F5DED94009B00D0 /* birch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = birch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = birchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CC0000010000000000000001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
|
||||
CC0000010000000000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
CC0000010000000000000003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
CC0000010000000000000004 /* Birch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Birch.xcconfig; sourceTree = "<group>"; };
|
||||
3C9ACE242F5DED94009B00D0 /* hellbender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hellbender.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 3C9ACE232F5DED94009B00D0 /* birch */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
3C9ACE262F5DED94009B00D0 /* birch */ = {
|
||||
3C9ACE262F5DED94009B00D0 /* hellbender */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CC0000010000000000000006 /* Exceptions for "birch" folder in "birch" target */,
|
||||
);
|
||||
path = birch;
|
||||
path = hellbender;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3C9ACE372F5DED95009B00D0 /* birchTests */ = {
|
||||
3C9ACE372F5DED95009B00D0 /* hellbenderTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = birchTests;
|
||||
path = hellbenderTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3C9ACE412F5DED95009B00D0 /* birchUITests */ = {
|
||||
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = birchUITests;
|
||||
path = hellbenderUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
@ -105,10 +88,9 @@
|
||||
3C9ACE1B2F5DED94009B00D0 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0000010000000000000005 /* Config */,
|
||||
3C9ACE262F5DED94009B00D0 /* birch */,
|
||||
3C9ACE372F5DED95009B00D0 /* birchTests */,
|
||||
3C9ACE412F5DED95009B00D0 /* birchUITests */,
|
||||
3C9ACE262F5DED94009B00D0 /* hellbender */,
|
||||
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
|
||||
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
|
||||
3C9ACE252F5DED94009B00D0 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@ -116,30 +98,19 @@
|
||||
3C9ACE252F5DED94009B00D0 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3C9ACE242F5DED94009B00D0 /* birch.app */,
|
||||
3C9ACE342F5DED95009B00D0 /* birchTests.xctest */,
|
||||
3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */,
|
||||
3C9ACE242F5DED94009B00D0 /* hellbender.app */,
|
||||
3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */,
|
||||
3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC0000010000000000000005 /* Config */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0000010000000000000001 /* Base.xcconfig */,
|
||||
CC0000010000000000000002 /* Debug.xcconfig */,
|
||||
CC0000010000000000000004 /* Birch.xcconfig */,
|
||||
CC0000010000000000000003 /* Release.xcconfig */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
3C9ACE232F5DED94009B00D0 /* birch */ = {
|
||||
3C9ACE232F5DED94009B00D0 /* hellbender */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */;
|
||||
buildConfigurationList = 3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */;
|
||||
buildPhases = (
|
||||
3C9ACE202F5DED94009B00D0 /* Sources */,
|
||||
3C9ACE212F5DED94009B00D0 /* Frameworks */,
|
||||
@ -150,9 +121,9 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3C9ACE262F5DED94009B00D0 /* birch */,
|
||||
3C9ACE262F5DED94009B00D0 /* hellbender */,
|
||||
);
|
||||
name = birch;
|
||||
name = hellbender;
|
||||
packageProductDependencies = (
|
||||
AA00000500000000000000D0 /* URKit */,
|
||||
AA00000800000000000000D0 /* URUI */,
|
||||
@ -160,13 +131,13 @@
|
||||
3C1E1C452F7B0D99002FDAE2 /* BitcoinDevKit */,
|
||||
3C1E1FA42F7B5F63002FDAE2 /* BitcoinDevKit */,
|
||||
);
|
||||
productName = birch;
|
||||
productReference = 3C9ACE242F5DED94009B00D0 /* birch.app */;
|
||||
productName = hellbender;
|
||||
productReference = 3C9ACE242F5DED94009B00D0 /* hellbender.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
3C9ACE332F5DED95009B00D0 /* birchTests */ = {
|
||||
3C9ACE332F5DED95009B00D0 /* hellbenderTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */;
|
||||
buildConfigurationList = 3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */;
|
||||
buildPhases = (
|
||||
3C9ACE302F5DED95009B00D0 /* Sources */,
|
||||
3C9ACE312F5DED95009B00D0 /* Frameworks */,
|
||||
@ -178,18 +149,18 @@
|
||||
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3C9ACE372F5DED95009B00D0 /* birchTests */,
|
||||
3C9ACE372F5DED95009B00D0 /* hellbenderTests */,
|
||||
);
|
||||
name = birchTests;
|
||||
name = hellbenderTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = birchTests;
|
||||
productReference = 3C9ACE342F5DED95009B00D0 /* birchTests.xctest */;
|
||||
productName = hellbenderTests;
|
||||
productReference = 3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
3C9ACE3D2F5DED95009B00D0 /* birchUITests */ = {
|
||||
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */;
|
||||
buildConfigurationList = 3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */;
|
||||
buildPhases = (
|
||||
3C9ACE3A2F5DED95009B00D0 /* Sources */,
|
||||
3C9ACE3B2F5DED95009B00D0 /* Frameworks */,
|
||||
@ -201,13 +172,13 @@
|
||||
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3C9ACE412F5DED95009B00D0 /* birchUITests */,
|
||||
3C9ACE412F5DED95009B00D0 /* hellbenderUITests */,
|
||||
);
|
||||
name = birchUITests;
|
||||
name = hellbenderUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = birchUITests;
|
||||
productReference = 3C9ACE3E2F5DED95009B00D0 /* birchUITests.xctest */;
|
||||
productName = hellbenderUITests;
|
||||
productReference = 3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@ -233,7 +204,7 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */;
|
||||
buildConfigurationList = 3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
@ -253,9 +224,9 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
3C9ACE232F5DED94009B00D0 /* birch */,
|
||||
3C9ACE332F5DED95009B00D0 /* birchTests */,
|
||||
3C9ACE3D2F5DED95009B00D0 /* birchUITests */,
|
||||
3C9ACE232F5DED94009B00D0 /* hellbender */,
|
||||
3C9ACE332F5DED95009B00D0 /* hellbenderTests */,
|
||||
3C9ACE3D2F5DED95009B00D0 /* hellbenderUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@ -311,12 +282,12 @@
|
||||
/* Begin PBXTargetDependency section */
|
||||
3C9ACE362F5DED95009B00D0 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 3C9ACE232F5DED94009B00D0 /* birch */;
|
||||
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
|
||||
targetProxy = 3C9ACE352F5DED95009B00D0 /* PBXContainerItemProxy */;
|
||||
};
|
||||
3C9ACE402F5DED95009B00D0 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 3C9ACE232F5DED94009B00D0 /* birch */;
|
||||
target = 3C9ACE232F5DED94009B00D0 /* hellbender */;
|
||||
targetProxy = 3C9ACE3F2F5DED95009B00D0 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
@ -324,35 +295,190 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
3C9ACE462F5DED95009B00D0 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = CC0000010000000000000002 /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = ZW85AH743B;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3C9ACE472F5DED95009B00D0 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = CC0000010000000000000003 /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = ZW85AH743B;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3C9ACE492F5DED95009B00D0 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
MARKETING_VERSION = 0.2.0;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Hellbender;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Hellbender uses Face ID to securely unlock your wallet.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3C9ACE4A2F5DED95009B00D0 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = CC0000010000000000000004 /* Birch.xcconfig */;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
MARKETING_VERSION = 0.2.0;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Hellbender;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Hellbender uses Face ID to securely unlock your wallet.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@ -370,7 +496,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/birch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/birch";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hellbender.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hellbender";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@ -388,7 +514,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/birch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/birch";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hellbender.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hellbender";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@ -404,7 +530,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = birch;
|
||||
TEST_TARGET_NAME = hellbender;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@ -420,14 +546,14 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = birch;
|
||||
TEST_TARGET_NAME = hellbender;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "birch" */ = {
|
||||
3C9ACE1F2F5DED94009B00D0 /* Build configuration list for PBXProject "hellbender" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3C9ACE462F5DED95009B00D0 /* Debug */,
|
||||
@ -436,7 +562,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birch" */ = {
|
||||
3C9ACE482F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbender" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3C9ACE492F5DED95009B00D0 /* Debug */,
|
||||
@ -445,7 +571,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchTests" */ = {
|
||||
3C9ACE4B2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3C9ACE4C2F5DED95009B00D0 /* Debug */,
|
||||
@ -454,7 +580,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "birchUITests" */ = {
|
||||
3C9ACE4E2F5DED95009B00D0 /* Build configuration list for PBXNativeTarget "hellbenderUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3C9ACE4F2F5DED95009B00D0 /* Debug */,
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
BIN
hellbender/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
hellbender/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
38
hellbender/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
38
hellbender/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
hellbender/Assets.xcassets/WelcomeIcon.imageset/AppIcon.png
vendored
Normal file
BIN
hellbender/Assets.xcassets/WelcomeIcon.imageset/AppIcon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Denomination
|
||||
|
||||
@ -58,29 +57,6 @@ extension String {
|
||||
guard count > leading + trailing + 3 else { return self }
|
||||
return "\(prefix(leading))...\(suffix(trailing))"
|
||||
}
|
||||
|
||||
/// Build a styled Text view with space-separated 4-character chunks,
|
||||
/// alternating between primary and secondary text colors.
|
||||
func chunkedAddressText(font: Font = .hbMono(13)) -> Text {
|
||||
var chunks: [String] = []
|
||||
var current = ""
|
||||
for (i, char) in enumerated() {
|
||||
if i > 0, i % 4 == 0 {
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
}
|
||||
current.append(char)
|
||||
}
|
||||
if !current.isEmpty { chunks.append(current) }
|
||||
|
||||
var result = Text("")
|
||||
for (i, chunk) in chunks.enumerated() {
|
||||
if i > 0 { result = result + Text(" ") }
|
||||
let color: Color = i % 2 == 0 ? .hbTextPrimary : .hbTextSecondary
|
||||
result = result + Text(chunk).font(font).foregroundColor(color)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
@ -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)"
|
||||
@ -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
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
@ -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 {
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -14,7 +14,6 @@ final class SetupWizardViewModel {
|
||||
case descriptorImport
|
||||
case walletName
|
||||
case review
|
||||
case verify
|
||||
}
|
||||
|
||||
enum CreationMode {
|
||||
@ -47,10 +46,6 @@ final class SetupWizardViewModel {
|
||||
var externalDescriptor: String = ""
|
||||
var internalDescriptor: String = ""
|
||||
|
||||
// Verification step
|
||||
var firstReceiveAddress: String = ""
|
||||
var addressDerivationError: String?
|
||||
|
||||
// Electrum server
|
||||
var electrumHost: String = ""
|
||||
var electrumPort: String = ""
|
||||
@ -96,7 +91,7 @@ final class SetupWizardViewModel {
|
||||
|
||||
/// Progress
|
||||
var stepCount: Int {
|
||||
creationMode == .createNew ? 6 : 3
|
||||
creationMode == .createNew ? 5 : 3
|
||||
}
|
||||
|
||||
var currentStepIndex: Int {
|
||||
@ -108,7 +103,6 @@ final class SetupWizardViewModel {
|
||||
case .descriptorImport: 2
|
||||
case .walletName: creationMode == .createNew ? 4 : 3
|
||||
case .review: stepCount - 1
|
||||
case .verify: stepCount - 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,17 +187,13 @@ final class SetupWizardViewModel {
|
||||
func buildDescriptors() {
|
||||
guard allCosignersComplete else { return }
|
||||
|
||||
// Build key origin strings — normalize to standard xpub/tpub format before
|
||||
// sorting so BIP67 ordering matches what's emitted in the descriptor.
|
||||
let isTestnet = network != .mainnet
|
||||
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
|
||||
var keyEntries: [(origin: String, xpub: String, fingerprint: String, path: String, label: String, index: Int)] = []
|
||||
|
||||
for i in 0 ..< totalCosigners {
|
||||
let raw = cosignerXpubs[i].trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let normalized = URService.normalizeXpub(raw, isTestnet: isTestnet) ?? raw
|
||||
keyEntries.append((
|
||||
origin: "[\(cosignerFingerprints[i])/48'/\(network.coinType)'/0'/2']",
|
||||
xpub: normalized,
|
||||
xpub: cosignerXpubs[i],
|
||||
fingerprint: cosignerFingerprints[i],
|
||||
path: cosignerDerivationPaths[i],
|
||||
label: cosignerLabels[i],
|
||||
@ -215,48 +205,18 @@ final class SetupWizardViewModel {
|
||||
keyEntries.sort { $0.xpub < $1.xpub }
|
||||
|
||||
let externalKeys = keyEntries.map {
|
||||
"\($0.origin)\($0.xpub)/0/*"
|
||||
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
return "\($0.origin)\(xpub)/0/*"
|
||||
}.joined(separator: ",")
|
||||
let internalKeys = keyEntries.map {
|
||||
"\($0.origin)\($0.xpub)/1/*"
|
||||
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
return "\($0.origin)\(xpub)/1/*"
|
||||
}.joined(separator: ",")
|
||||
|
||||
externalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(externalKeys)))"
|
||||
internalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(internalKeys)))"
|
||||
}
|
||||
|
||||
var combinedDescriptor: String {
|
||||
let cosignerData = (0 ..< totalCosigners).map {
|
||||
(xpub: cosignerXpubs[$0], fingerprint: cosignerFingerprints[$0], derivationPath: cosignerDerivationPaths[$0])
|
||||
}
|
||||
return BitcoinService.buildCombinedDescriptor(
|
||||
requiredSignatures: requiredSignatures,
|
||||
cosigners: cosignerData,
|
||||
network: network
|
||||
)
|
||||
}
|
||||
|
||||
func deriveFirstAddress() {
|
||||
let bdkNetwork = BitcoinService.shared.bdkNetwork(from: network)
|
||||
do {
|
||||
let extDesc = try Descriptor(descriptor: externalDescriptor, network: bdkNetwork)
|
||||
let chgDesc = try Descriptor(descriptor: internalDescriptor, network: bdkNetwork)
|
||||
let persister = try Persister.newInMemory()
|
||||
let tempWallet = try Wallet(
|
||||
descriptor: extDesc,
|
||||
changeDescriptor: chgDesc,
|
||||
network: bdkNetwork,
|
||||
persister: persister
|
||||
)
|
||||
let info = tempWallet.peekAddress(keychain: .external, index: 0)
|
||||
firstReceiveAddress = info.address.description
|
||||
addressDerivationError = nil
|
||||
} catch {
|
||||
addressDerivationError = "Failed to derive address: \(error.localizedDescription)"
|
||||
firstReceiveAddress = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseImportedDescriptor() -> Bool {
|
||||
// If the input is a JSON object (e.g. Specter Desktop export), extract the descriptor field
|
||||
if let descriptor = URService.extractDescriptorFromJSON(importedDescriptorText) {
|
||||
@ -432,11 +392,8 @@ final class SetupWizardViewModel {
|
||||
}
|
||||
currentStep = .walletName
|
||||
case .walletName:
|
||||
deriveFirstAddress()
|
||||
currentStep = .verify
|
||||
currentStep = .review
|
||||
case .review:
|
||||
break // unused in current flow
|
||||
case .verify:
|
||||
break // handled by saveWallet
|
||||
}
|
||||
}
|
||||
@ -454,7 +411,6 @@ final class SetupWizardViewModel {
|
||||
}
|
||||
// Import flow: back button is hidden, wallet already created
|
||||
case .review: currentStep = .walletName
|
||||
case .verify: currentStep = .walletName
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -14,7 +14,6 @@ struct HBTheme {
|
||||
var heroBackground: Color
|
||||
var success: Color
|
||||
var error: Color
|
||||
var secondaryAccent: Color
|
||||
var colorScheme: ColorScheme? = .dark
|
||||
|
||||
static let system = HBTheme(
|
||||
@ -28,7 +27,6 @@ struct HBTheme {
|
||||
heroBackground: Color(.tertiarySystemBackground),
|
||||
success: Color(.systemGreen),
|
||||
error: Color(.systemRed),
|
||||
secondaryAccent: Color(.systemBlue),
|
||||
colorScheme: nil
|
||||
)
|
||||
|
||||
@ -43,7 +41,6 @@ struct HBTheme {
|
||||
heroBackground: Color(red: 0.110, green: 0.110, blue: 0.141),
|
||||
success: Color(red: 0.176, green: 0.545, blue: 0.341),
|
||||
error: Color(red: 0.851, green: 0.267, blue: 0.267),
|
||||
secondaryAccent: Color(red: 0.290, green: 0.565, blue: 0.851),
|
||||
colorScheme: .dark
|
||||
)
|
||||
|
||||
@ -58,37 +55,6 @@ struct HBTheme {
|
||||
heroBackground: Color(red: 0.930, green: 0.930, blue: 0.945),
|
||||
success: Color(red: 0.204, green: 0.780, blue: 0.349),
|
||||
error: Color(red: 1.000, green: 0.231, blue: 0.188),
|
||||
secondaryAccent: Color(red: 0.290, green: 0.565, blue: 0.851),
|
||||
colorScheme: .light
|
||||
)
|
||||
|
||||
static let birchDark = HBTheme(
|
||||
background: Color(red: 0.102, green: 0.094, blue: 0.078),
|
||||
surface: Color(red: 0.141, green: 0.125, blue: 0.094),
|
||||
surfaceElevated: Color(red: 0.180, green: 0.157, blue: 0.125),
|
||||
border: Color(red: 0.239, green: 0.208, blue: 0.188),
|
||||
textPrimary: Color(red: 0.929, green: 0.910, blue: 0.875),
|
||||
textSecondary: Color(red: 0.659, green: 0.620, blue: 0.573),
|
||||
accent: Color(red: 0.831, green: 0.659, blue: 0.208),
|
||||
heroBackground: Color(red: 0.141, green: 0.125, blue: 0.094),
|
||||
success: Color(red: 0.416, green: 0.478, blue: 0.322),
|
||||
error: Color(red: 0.690, green: 0.235, blue: 0.157),
|
||||
secondaryAccent: Color(red: 0.416, green: 0.478, blue: 0.322),
|
||||
colorScheme: .dark
|
||||
)
|
||||
|
||||
static let birchLight = HBTheme(
|
||||
background: Color(red: 0.949, green: 0.933, blue: 0.902),
|
||||
surface: Color(red: 0.890, green: 0.867, blue: 0.827),
|
||||
surfaceElevated: Color(red: 0.949, green: 0.933, blue: 0.902),
|
||||
border: Color(red: 0.690, green: 0.663, blue: 0.616),
|
||||
textPrimary: Color(red: 0.137, green: 0.122, blue: 0.106),
|
||||
textSecondary: Color(red: 0.310, green: 0.282, blue: 0.251),
|
||||
accent: Color(red: 0.698, green: 0.525, blue: 0.133),
|
||||
heroBackground: Color(red: 0.890, green: 0.867, blue: 0.827),
|
||||
success: Color(red: 0.278, green: 0.373, blue: 0.224),
|
||||
error: Color(red: 0.600, green: 0.180, blue: 0.120),
|
||||
secondaryAccent: Color(red: 0.278, green: 0.373, blue: 0.224),
|
||||
colorScheme: .light
|
||||
)
|
||||
}
|
||||
@ -99,16 +65,12 @@ enum AppTheme: String, CaseIterable {
|
||||
case system
|
||||
case dark
|
||||
case light
|
||||
case birchDark
|
||||
case birchLight
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .system: "System"
|
||||
case .dark: "Hellbender Dark"
|
||||
case .light: "Hellbender Light"
|
||||
case .birchDark: "Birch Dark"
|
||||
case .birchLight: "Birch Light"
|
||||
case .dark: "Dark"
|
||||
case .light: "Light"
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,8 +79,6 @@ enum AppTheme: String, CaseIterable {
|
||||
case .system: .system
|
||||
case .dark: .dark
|
||||
case .light: .light
|
||||
case .birchDark: .birchDark
|
||||
case .birchLight: .birchLight
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -128,7 +88,7 @@ enum AppTheme: String, CaseIterable {
|
||||
@Observable
|
||||
final class ThemeManager {
|
||||
static let shared = ThemeManager()
|
||||
private(set) var theme: HBTheme = .birchDark
|
||||
private(set) var theme: HBTheme = .dark
|
||||
|
||||
private init() {
|
||||
let saved = UserDefaults.standard.string(forKey: Constants.themeKey) ?? AppTheme.system.rawValue
|
||||
@ -143,7 +103,7 @@ final class ThemeManager {
|
||||
/// Sets the displayed theme to the appropriate custom palette for the given OS color scheme.
|
||||
/// Only used when the System theme is selected — does not save to UserDefaults.
|
||||
func applySystemColorScheme(_ colorScheme: ColorScheme) {
|
||||
theme = colorScheme == .dark ? .birchDark : .birchLight
|
||||
theme = colorScheme == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,9 +137,7 @@ extension Color {
|
||||
ThemeManager.shared.theme.accent
|
||||
}
|
||||
|
||||
static var hbSteelBlue: Color {
|
||||
ThemeManager.shared.theme.secondaryAccent
|
||||
}
|
||||
static let hbSteelBlue = Color(red: 0.290, green: 0.565, blue: 0.851) // #4A90D9 — not themed
|
||||
|
||||
/// Semantic
|
||||
static var hbSuccess: Color {
|
||||
@ -288,21 +246,6 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Themed App Icon
|
||||
|
||||
/// Renders the app-icon artwork that matches the current theme's light/dark appearance.
|
||||
/// Uses `AppIconPreviewLight` on light color schemes, `AppIconPreviewDark` on dark.
|
||||
/// The theme is applied via `.preferredColorScheme` at the root, so this works for
|
||||
/// all AppTheme cases (system, birch light/dark).
|
||||
struct ThemedAppIcon: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Image(colorScheme == .dark ? "AppIconPreviewDark" : "AppIconPreviewLight")
|
||||
.resizable()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network Badge
|
||||
|
||||
struct NetworkBadge: View {
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -26,7 +26,9 @@ struct AddressDetailView: View {
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
address.chunkedAddressText()
|
||||
Text(address)
|
||||
.font(.hbMono(13))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.textSelection(.enabled)
|
||||
@ -37,9 +37,10 @@ struct ReceiveView: View {
|
||||
.font(.hbLabel())
|
||||
.foregroundStyle(Color.hbTextSecondary)
|
||||
|
||||
viewModel.currentAddress.chunkedAddressText()
|
||||
Text(viewModel.currentAddress)
|
||||
.font(.hbMono(13))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 32)
|
||||
.textSelection(.enabled)
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
@ -338,11 +338,17 @@ private struct PSBTReviewCard: View {
|
||||
ForEach(Array(viewModel.recipients.enumerated()), id: \.element.id) { index, recipient in
|
||||
if viewModel.recipients.count > 1 {
|
||||
ReviewItem(label: "Recipient \(index + 1)") {
|
||||
recipient.address.chunkedAddressText(font: .hbMono(12))
|
||||
Text(recipient.address)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
} else {
|
||||
ReviewItem(label: "To") {
|
||||
recipient.address.chunkedAddressText(font: .hbMono(12))
|
||||
Text(recipient.address)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,11 +24,17 @@ struct SendReviewView: View {
|
||||
ForEach(Array(viewModel.recipients.enumerated()), id: \.element.id) { index, recipient in
|
||||
if viewModel.recipients.count > 1 {
|
||||
ReviewItem(label: "Recipient \(index + 1)") {
|
||||
recipient.address.chunkedAddressText(font: .hbMono(12))
|
||||
Text(recipient.address)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
} else {
|
||||
ReviewItem(label: "To") {
|
||||
recipient.address.chunkedAddressText(font: .hbMono(12))
|
||||
Text(recipient.address)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
@ -2,7 +2,7 @@ import OSLog
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "UTXODetail")
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "UTXODetail")
|
||||
|
||||
struct UTXODetailView: View {
|
||||
let utxo: UTXOItem
|
||||
@ -110,7 +110,9 @@ struct UTXODetailView: View {
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
} else {
|
||||
address.chunkedAddressText(font: .hbMono(12))
|
||||
Text(address)
|
||||
.font(.hbMono(12))
|
||||
.foregroundStyle(Color.hbTextPrimary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
@ -154,11 +156,6 @@ struct UTXODetailView: View {
|
||||
|
||||
DetailRow(label: "Amount", value: isPrivate ? Constants.privacyText() : utxo.amount.formattedSats)
|
||||
|
||||
DetailRow(
|
||||
label: utxo.keychain == .external ? "Receive Address Index" : "Change Address Index",
|
||||
value: "\(utxo.derivationIndex)"
|
||||
)
|
||||
|
||||
DetailRow(label: "Output Index", value: "\(utxo.vout)")
|
||||
|
||||
DetailRow(label: "Type", value: utxo.keychain == .external ? "Receive" : "Change")
|
||||
@ -175,7 +172,7 @@ struct UTXODetailView: View {
|
||||
}
|
||||
|
||||
DetailRow(label: "Confirmations",
|
||||
value: "\(tx.confirmations)")
|
||||
value: tx.confirmations >= 6 ? "6+" : "\(tx.confirmations)")
|
||||
}
|
||||
}
|
||||
.hbCard()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user