diff --git a/.envrc b/.envrc new file mode 100644 index 0000000000..3550a30f2d --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml new file mode 100644 index 0000000000..1821eddce2 --- /dev/null +++ b/.github/workflows/make-build.yaml @@ -0,0 +1,107 @@ +name: Make build + +# Builds with the Makefile targets on the hosted runners. Manual only. +on: + workflow_dispatch: + +jobs: + windows: + runs-on: windows-2022 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.2' + channel: 'stable' + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + - name: Install make + run: command -v make >/dev/null 2>&1 || choco install make -y --no-progress + - name: Build + run: make build-windows VERSION=0.0.0 BUILD_NUM=${{ github.run_number }} WINDOWS_COPY_ASSETS=1 + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.2' + channel: 'stable' + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + - name: Tools + run: | + brew install meson ninja autoconf automake libtool protobuf pkg-config cbindgen || true + rustup toolchain install 1.99.1 --profile minimal || true + rustup default 1.99.1 || true + - name: Build + run: make build-macos VERSION=0.0.0 BUILD_NUM=${{ github.run_number }} + + linux: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.2' + channel: 'stable' + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + df -h / + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + automake build-essential clang cmake libclang-dev libtool llvm meson ninja-build patchelf pkg-config protobuf-compiler rsync \ + libgcrypt20-dev libgirepository1.0-dev libgit2-dev libglib2.0-dev libgtk-3-dev libjsoncpp-dev liblzma-dev \ + libncurses5-dev libncursesw5-dev libopencv-dev libsecret-1-dev libssl-dev libtss2-dev \ + ocl-icd-opencl-dev opencl-headers valac zlib1g-dev + - name: Rust + run: | + rustup toolchain install 1.99.1 --profile minimal + rustup default 1.99.1 + - name: Check the toolchain bill of materials + run: make verify-bom + - name: Build + run: make build-linux VERSION=0.0.0 BUILD_NUM=${{ github.run_number }} + + ios: + # The fork has a single hosted macOS concurrency slot. Running macOS and + # iOS together leaves one job queued until GitHub cancels it; serialise them. + needs: macos + if: always() + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.2' + channel: 'stable' + - uses: actions/setup-go@v6 + with: + go-version: '1.24.13' + - name: Tools + run: | + brew install meson ninja autoconf automake libtool protobuf pkg-config cbindgen || true + rustup toolchain install 1.99.1 --profile minimal || true + rustup default 1.99.1 || true + - name: Build + run: make build-ios VERSION=0.0.0 BUILD_NUM=${{ github.run_number }} diff --git a/.gitignore b/.gitignore index 1b5b2b46cc..f3393861e8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.pyc *.swp .DS_Store +._* .atom/ .buildlog/ .history @@ -35,9 +36,13 @@ Microsoft.Windows* android/app/.cxx android/build/ +# Nix / direnv / project-local toolchain state +.direnv/ + # Web related lib/generated_plugin_registrant.dart + # testing data test/services/coins/bitcoin/bitcoin_wallet_test_parameters.dart test/services/coins/firo/firo_wallet_test_parameters.dart @@ -120,3 +125,12 @@ lib/wl_gen/generated/ /assets/windows/mwebd.exe /tool/build +bm/results/ +/.cargo/config.toml +/scripts/linux/pc/ + +# Nix dev shell: generated wrappers +.nix-bin/ +.direnv/ +/android/key.properties +/android/dev-keystore.jks diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..f537d2def3 --- /dev/null +++ b/Makefile @@ -0,0 +1,308 @@ +# ============================================================================== +# Stack Wallet build helper. Wraps scripts/build_app.sh; see docs/building.md +# Usage: make VERSION=x.x.x BUILD_NUM=xxx +# ============================================================================== + +APP_NAME ?= stack_wallet +VERSION ?= 2.1.0 +BUILD_NUM ?= 210 +FLUTTER ?= +DART ?= +FLUTTER_BIN := $(if $(and $(FLUTTER),$(wildcard $(FLUTTER))),$(FLUTTER),$(shell command -v flutter 2>/dev/null)) +DART_BIN := $(if $(and $(DART),$(wildcard $(DART))),$(DART),$(shell command -v dart 2>/dev/null)) +FLUTTER := $(FLUTTER_BIN) +DART := $(DART_BIN) +APP_PROJECT_ROOT_DIR := $(CURDIR) +# Platform detection from uname. +PLATFORM ?= $(shell uname -s 2>/dev/null) +# Linux builds use a project-local pub cache so cargo finds the repo-local +# .cargo/config.toml that carries PROTOC through Flutter's filtered hook environment. +# Other targets leave PUB_CACHE unset, so Flutter uses its normal per-user cache +# and `make` and the IDE share native-asset outputs instead of rebuilding them. +LINUX_PUB_CACHE ?= $(APP_PROJECT_ROOT_DIR)/.pub-cache +PUB_CACHE ?= +PROTOC_PATH := $(shell which protoc 2>/dev/null) +# Toolchain installed for host-side checks/targets. Native-assets crates pick their own +# pinned toolchain via rust-toolchain.toml; do not export RUSTUP_TOOLCHAIN globally. +RUST_VERSION ?= 1.99.1 +MACOS_ENV_UNSET = -u LD -u LDFLAGS -u NIX_LDFLAGS -u NIX_CFLAGS_LINK \ + -u CFLAGS -u CXXFLAGS -u CPPFLAGS \ + -u SDKROOT -u BINDGEN_EXTRA_CLANG_ARGS \ + -u IPHONEOS_DEPLOYMENT_TARGET -u TVOS_DEPLOYMENT_TARGET -u WATCHOS_DEPLOYMENT_TARGET \ + -u XROS_DEPLOYMENT_TARGET -u XR_DEPLOYMENT_TARGET + +export APP_PROJECT_ROOT_DIR +ifneq ($(strip $(PUB_CACHE)),) +export PUB_CACHE +endif + +.PHONY: help check-reqs check-reqs-macos check-sdk-macos bootstrap-macos bootstrap-xcode bootstrap-xcode-download sccache-setup cache-stats verify-bom clean clean-app clean-native build-macos build-linux build-windows diagnose-env-macos build-android android-dev-key build-ios + +help: ## Show available commands + @echo "Available targets:" + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "%-20s %s\n", $$1, $$2}' + +# --- PREREQUISITES --- + +check-reqs: ## Verify essential build tools + @echo "Checking core prerequisites..." + @[ -n "$(FLUTTER)" ] && command -v "$(FLUTTER)" >/dev/null 2>&1 || { echo >&2 "[ERROR] Flutter not installed."; exit 1; } + @[ -n "$(DART)" ] && command -v "$(DART)" >/dev/null 2>&1 || { echo >&2 "[ERROR] Dart not installed."; exit 1; } + @command -v rustup >/dev/null 2>&1 || { echo >&2 "[ERROR] rustup not installed."; exit 1; } + @rustup which rustc >/dev/null 2>&1 || { echo >&2 "[ERROR] rustc toolchain not available via rustup."; exit 1; } + @rustup which cargo >/dev/null 2>&1 || { echo >&2 "[ERROR] cargo toolchain not available via rustup."; exit 1; } + @# `rustup which cargo` resolves a toolchain path even when no shim is on PATH, + @# but the plugin build scripts invoke bare `cargo` — verify that separately. + @command -v cargo >/dev/null 2>&1 || [ -x "$$HOME/.cargo/bin/cargo" ] || { echo >&2 "[ERROR] 'cargo' is not on PATH (rustup shims missing from ~/.cargo/bin). Run 'rustup-init -y', then open a new terminal."; exit 1; } + @command -v go >/dev/null 2>&1 || { echo >&2 "[ERROR] Go not installed."; exit 1; } + @command -v cmake >/dev/null 2>&1 || { echo >&2 "[ERROR] CMake not installed."; exit 1; } + @command -v meson >/dev/null 2>&1 || { \ + if [ "$(PLATFORM)" = "Darwin" ]; then \ + echo >&2 "[ERROR] Meson not installed. On macOS, run 'make bootstrap-macos' or 'brew install meson'."; \ + elif [ "$(PLATFORM)" = "Linux" ]; then \ + echo >&2 "[ERROR] Meson not installed. On Linux/NixOS, run in 'nix develop' or install meson via your package manager."; \ + else \ + echo >&2 "[ERROR] Meson not installed. Install via your package manager."; \ + fi; \ + exit 1; \ + } + @command -v ninja >/dev/null 2>&1 || { \ + if [ "$(PLATFORM)" = "Darwin" ]; then \ + echo >&2 "[ERROR] Ninja not installed. On macOS, run 'make bootstrap-macos' or 'brew install ninja'."; \ + elif [ "$(PLATFORM)" = "Linux" ]; then \ + echo >&2 "[ERROR] Ninja not installed. On Linux/NixOS, run in 'nix develop' or install ninja via your package manager."; \ + else \ + echo >&2 "[ERROR] Ninja not installed. Install via your package manager."; \ + fi; \ + exit 1; \ + } +ifneq ($(OS),Windows_NT) + @command -v pkg-config >/dev/null 2>&1 || { echo >&2 "[ERROR] pkg-config not installed."; exit 1; } +endif +ifeq ($(PLATFORM),Darwin) + @command -v autoreconf >/dev/null 2>&1 || { echo >&2 "[ERROR] autoconf/autoreconf not installed."; exit 1; } + @command -v aclocal >/dev/null 2>&1 || { echo >&2 "[ERROR] automake/aclocal not installed."; exit 1; } +endif +ifeq ($(PLATFORM),Linux) + @command -v python3 >/dev/null 2>&1 || { echo >&2 "[ERROR] python3 not installed (needed to prepare the Linux release bundle)."; exit 1; } + @command -v patchelf >/dev/null 2>&1 || { echo >&2 "[ERROR] patchelf not installed (Ubuntu 24.04: apt-get install patchelf binutils)."; exit 1; } + @command -v readelf >/dev/null 2>&1 || { echo >&2 "[ERROR] readelf not installed (apt-get install binutils)."; exit 1; } +endif + @echo "[OK] All core CLI requirements found!" + +check-sdk-macos: ## Verify XCode on macOS +ifeq ($(PLATFORM),Darwin) + @echo "Checking macOS SDK requirements..." + @xcrun --sdk macosx --show-sdk-path >/dev/null 2>&1 || ( \ + echo "[ERROR] macOS SDK not available. Install Xcode or run: sudo xcode-select --switch /Applications/Xcode.app"; \ + exit 1) + @echo "[OK] macOS SDK available at: $$(xcrun --sdk macosx --show-sdk-path)" +endif + +check-reqs-macos: check-reqs ## Verify macOS-specific tools are available in PATH +ifeq ($(PLATFORM),Darwin) + @echo "Checking macOS-specific tools in PATH..." + @rustup run $(RUST_VERSION) rustc -vV >/dev/null 2>&1 || { echo >&2 "[ERROR] Rust $(RUST_VERSION) toolchain not available. Run 'make bootstrap-macos'."; exit 1; } + @command -v pod >/dev/null 2>&1 || { echo >&2 "[ERROR] CocoaPods (pod) not installed."; exit 1; } + @command -v xcodebuild >/dev/null 2>&1 || { echo >&2 "[ERROR] xcodebuild not available."; exit 1; } + @echo "[OK] macOS-specific toolchain is available." +else + @echo "[ERROR] check-reqs-macos is macOS-only." + @exit 1 +endif + +bootstrap-macos: ## Install required macOS build tools via Homebrew helper script +ifeq ($(PLATFORM),Darwin) + @if [ -n "$$IN_NIX_SHELL" ] || [ -n "$$NIX_BUILD_TOP" ]; then \ + echo "[WARN] Nix environment detected; skipping the Homebrew package install (use nix/flake-provided toolchain instead)."; \ + echo "[WARN] Xcode itself is never provided by Nix (Apple's license forbids redistributing it), so that part still runs:"; \ + $(MAKE) bootstrap-xcode || exit 1; \ + else \ + bash scripts/install_macos_build_tools.sh || exit 1; \ + rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --toolchain $(RUST_VERSION) >/dev/null 2>&1 || true; \ + fi +else + @echo "[ERROR] bootstrap-macos is macOS-only." + @exit 1 +endif + +bootstrap-xcode: ## Install/select full Xcode.app (needed regardless of Homebrew vs Nix for the rest of the toolchain) +ifeq ($(PLATFORM),Darwin) + @bash scripts/install_xcode.sh +else + @echo "[ERROR] bootstrap-xcode is macOS-only." + @exit 1 +endif + +bootstrap-xcode-download: ## Opt-in: download Xcode from archive.org (SHA-1 + Apple signature checked); XCODE_VERSION=27.1, ARGS=-y for non-interactive +ifeq ($(PLATFORM),Darwin) + @bash scripts/download_xcode.sh $(if $(XCODE_VERSION),-v $(XCODE_VERSION)) $(ARGS) +else + @echo "[ERROR] bootstrap-xcode-download is macOS-only." + @exit 1 +endif + +SCCACHE_CARGO_CONFIG ?= $(HOME)/.cargo/config.toml + +sccache-setup: ## Opt-in: use sccache for the Rust/C builds run by native-asset hooks (writes ~/.cargo/config.toml if absent) + @command -v sccache >/dev/null 2>&1 || { echo >&2 "[ERROR] sccache not installed (brew install sccache)."; exit 1; } + @if [ -e "$(SCCACHE_CARGO_CONFIG)" ]; then \ + echo "[INFO] $(SCCACHE_CARGO_CONFIG) already exists, not touching it. Make sure it contains:"; \ + printf ' [build]\n rustc-wrapper = "sccache"\n'; \ + else \ + mkdir -p "$$(dirname "$(SCCACHE_CARGO_CONFIG)")"; \ + printf '[build]\nrustc-wrapper = "sccache"\n' > "$(SCCACHE_CARGO_CONFIG)"; \ + echo "[OK] Wrote $(SCCACHE_CARGO_CONFIG). Remove the file to turn sccache off."; \ + fi + @echo "Why a config file: with Flutter 3.47.x (hooks_runner 1.5.0) exported variables such as RUSTC_WRAPPER" + @echo "never reach the hooks; cargo reads this file via HOME. Flutter versions with hooks_runner >= 1.6.2" + @echo "also pass CARGO_BUILD_RUSTC_WRAPPER=sccache from the environment. See sccache --show-stats." + +verify-bom: ## Check the local toolchain and the pinned plugins against stack-bom.toml + @FLUTTER="$(FLUTTER)" $(DART) tool/verify_bom.dart + +cache-stats: ## Show sccache statistics (hit rate) after a build + @command -v sccache >/dev/null 2>&1 || { echo >&2 "[ERROR] sccache not installed."; exit 1; } + @sccache --show-stats + +clean: ## Remove artifacts and fix permissions + @echo "Cleaning Flutter and Rust artifacts..." + @chflags -R nouchg build/ macos/ 2>/dev/null || true + @chmod -R u+w build/ macos/ 2>/dev/null || true + @if [ -f pubspec.yaml ]; then $(FLUTTER) clean; fi + @if [ -f "Cargo.toml" ]; then cargo clean; fi + @rm -rf macos/Pods macos/Podfile.lock ios/Pods ios/Podfile.lock build/ + @echo "Cleaning local pub cache residues..." + @chmod -R u+w $(LINUX_PUB_CACHE)/git/ 2>/dev/null || true + @find $(LINUX_PUB_CACHE)/git/ -type d \( -name "build" -o -name "target" \) -path "*flutter_lib*" -exec rm -rf {} + 2>/dev/null || true + @echo "[OK] Project is now in a pristine state." + +# --- PLATFORM BUILDS --- + +PREBUILTS ?= 1 +# Linux only: SYSTEM_DEPS=1 uses the system libsecret/jsoncpp packages instead of building them (like the CI image). +SYSTEM_DEPS ?= 0 +LINUX_ARCH := $(shell uname -m | sed -e 's/x86_64/x64/' -e 's/aarch64/arm64/') +LINUX_BUNDLE ?= build/linux/$(LINUX_ARCH)/release/bundle + +clean-app: ## Remove app build output (build/, Pods) but keep the native-asset build outputs, so the next build is fast + @chflags -R nouchg build/ macos/ 2>/dev/null || true + @chmod -R u+w build/ macos/ 2>/dev/null || true + @rm -rf build/ macos/Pods macos/Podfile.lock ios/Pods ios/Podfile.lock + @echo "[OK] Removed build/ and Pods; .dart_tool/hooks_runner is kept." + +clean-native: ## Remove only the native-asset build outputs (forces the Rust/Go/C libraries to be rebuilt) + @chmod -R u+w .dart_tool/hooks_runner build/native_assets 2>/dev/null || true + @rm -rf .dart_tool/hooks_runner build/native_assets + @echo "[OK] Removed .dart_tool/hooks_runner and build/native_assets." + +build-macos: check-reqs-macos check-sdk-macos ## Build macOS release (same steps as upstream CI; PREBUILTS=0 compiles native assets from source) + @echo "--- Configuring project (scripts/build_app.sh)..." + @cd scripts && echo yes | env $(MACOS_ENV_UNSET) ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p macos -a $(APP_NAME) -s $(if $(filter 1,$(PREBUILTS)),-d) + @echo "--- Resolving dependencies..." + @env $(MACOS_ENV_UNSET) $(FLUTTER) pub get + @cd scripts && env $(MACOS_ENV_UNSET) bash prebuild.sh + @echo "--- Final Compilation..." + @env $(MACOS_ENV_UNSET) $(FLUTTER) build macos --release + + +diagnose-env-macos: ## Print macOS build env and tool resolution + @echo "--- Toolchain diagnostics ---" + @echo "flutter: $$(command -v $(FLUTTER) || echo missing)" + @echo "dart: $$(command -v $(DART) || echo missing)" + @echo "xcrun: $$(command -v xcrun || echo missing)" + @echo "clang: $$(command -v clang || echo missing)" + @echo "go: $$(command -v go || echo missing)" + @echo "rustup: $$(command -v rustup || echo missing)" + @echo "rustc: $$(command -v rustc || echo missing)" + @echo "cargo: $$(command -v cargo || echo missing)" + @echo "rustup rustc: $$(rustup which rustc 2>/dev/null || echo missing)" + @echo "rustup cargo: $$(rustup which cargo 2>/dev/null || echo missing)" + @echo "xcode-select: $$(xcode-select -p 2>/dev/null || echo missing)" + @echo "SDKROOT=$${SDKROOT:-}" + @echo "MACOSX_DEPLOYMENT_TARGET=$${MACOSX_DEPLOYMENT_TARGET:-}" + @echo "IPHONEOS_DEPLOYMENT_TARGET=$${IPHONEOS_DEPLOYMENT_TARGET:-}" + @echo "TVOS_DEPLOYMENT_TARGET=$${TVOS_DEPLOYMENT_TARGET:-}" + @echo "WATCHOS_DEPLOYMENT_TARGET=$${WATCHOS_DEPLOYMENT_TARGET:-}" + @echo "XROS_DEPLOYMENT_TARGET=$${XROS_DEPLOYMENT_TARGET:-}" + @echo "XR_DEPLOYMENT_TARGET=$${XR_DEPLOYMENT_TARGET:-}" + @echo "NIX_LDFLAGS=$${NIX_LDFLAGS:-}" + @echo "NIX_CFLAGS_LINK=$${NIX_CFLAGS_LINK:-}" + +build-linux: export PUB_CACHE := $(LINUX_PUB_CACHE) +build-linux: check-reqs ## Build Linux Release through Flutter native-assets hooks + @echo "--- Running prebuild bootstrap..." + @cd scripts && bash prebuild.sh + @echo "--- Generating config..." + @if [ -z "$(PROTOC_PATH)" ]; then echo "[ERROR] protoc not found!"; exit 1; fi + @cd scripts && yes yes | BUILD_ISAR_FROM_SOURCE=0 PROTOC="$(PROTOC_PATH)" ./build_app.sh -a $(APP_NAME) -p linux -v $(VERSION) -b $(BUILD_NUM) $(if $(filter 1,$(PREBUILTS)),-d) $(if $(filter 1,$(SYSTEM_DEPS)),-s) + @# Flutter 3.47.x hooks only see a filtered environment, so PROTOC (exported by the flake) never + @# reaches cargo build scripts. Some crates (prost-build 0.7) ship a generic-Linux protoc that cannot + @# run on NixOS. cargo reads [env] from .cargo/config.toml found from the package dir upwards, which + @# works because PUB_CACHE is project-local. Existing files are not touched. + @if [ ! -e .cargo/config.toml ]; then \ + mkdir -p .cargo; \ + printf '[env]\nPROTOC = "%s"\n' "$(PROTOC_PATH)" > .cargo/config.toml; \ + echo "[OK] Wrote .cargo/config.toml (PROTOC=$(PROTOC_PATH))"; \ + else \ + echo "[INFO] .cargo/config.toml exists; make sure it sets [env] PROTOC if protoc fails to run (NixOS)."; \ + fi + @echo "--- Building app..." + @$(FLUTTER) pub get + @if [ "$(SYSTEM_DEPS)" = "1" ]; then \ + USE_SYSTEM_SECURE_STORAGE_DEPS=1 PROTOC="$(PROTOC_PATH)" $(FLUTTER) build linux --release; \ + else \ + SYSPROF_PC_DIR=$$(dirname "$$(find /nix/store -path '*/lib/pkgconfig/sysprof-capture-4.pc' 2>/dev/null | head -n1)"); \ + PC_PATH=$$(pkg-config --variable=pc_path pkg-config 2>/dev/null || echo ""); \ + PKG_CONFIG_DISABLE_UNINSTALLED=1 PKG_CONFIG_PATH= \ + PKG_CONFIG_LIBDIR="$(CURDIR)/scripts/linux/pc:$$SYSPROF_PC_DIR:$$PC_PATH" \ + pkg-config --modversion libsecret-1 >/dev/null || \ + { echo "[ERROR] libsecret-1 not resolvable via pkg-config"; exit 1; }; \ + PKG_CONFIG_DISABLE_UNINSTALLED=1 PKG_CONFIG_PATH= \ + PKG_CONFIG_LIBDIR="$(CURDIR)/scripts/linux/pc:$$SYSPROF_PC_DIR:$$PC_PATH" \ + PROTOC="$(PROTOC_PATH)" $(FLUTTER) build linux --release; \ + fi + @echo "--- Preparing release bundle..." + @python3 scripts/linux/prepare_release_bundle.py $(LINUX_BUNDLE) + +# Windows: run from Git Bash. Same steps as the "build-windows" job in .github/workflows/build.yaml. +# WINDOWS_COPY_ASSETS=1 makes link_assets.sh copy instead of creating symlinks, for hosts +# where the user cannot create them (for example the GitHub-hosted runner). +WINDOWS_COPY_ASSETS ?= 0 + +build-windows: ## Build Windows release (Git Bash; same steps as CI) + @command -v flutter >/dev/null 2>&1 || { echo >&2 "[ERROR] Flutter not installed."; exit 1; } + @command -v go >/dev/null 2>&1 || { echo >&2 "[ERROR] Go not installed."; exit 1; } + @cd scripts && bash prebuild.sh + @cd scripts && echo yes | MWEBD_FETCH=1 WINDOWS_COPY_ASSETS=$(WINDOWS_COPY_ASSETS) ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p windows -a $(APP_NAME) -d -s + @$(FLUTTER) pub get + @$(DART) run coinlib:build_windows + @$(FLUTTER) build windows --release + +# Android: same steps as the "build-android" CI job, without the signing secrets. Needs the Android SDK/NDK +# (ANDROID_SDK_ROOT) and Java; the CI image ghcr.io//stackwallet-ci:android is built from the Dockerfile +# (docker build --target android). Without android/key.properties a throw-away debug key is created, so the APKs +# are not suitable for distribution. +android-dev-key: ## Create a throw-away debug signing key if android/key.properties is missing (never use for releases) + @if [ ! -f android/key.properties ]; then \ + echo "[INFO] android/key.properties missing: creating a throw-away debug key (not for releases)"; \ + keytool -genkeypair -keystore android/dev-keystore.jks -storetype pkcs12 -storepass android -keypass android \ + -alias devkey -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Stack Wallet dev build" -noprompt && \ + printf 'storeFile=../dev-keystore.jks\nstorePassword=android\nkeyPassword=android\nkeyAlias=devkey\n' > android/key.properties; \ + fi + +build-android: android-dev-key ## Build Android APKs (same steps as CI; signed with a throw-away key unless android/key.properties exists) + @command -v flutter >/dev/null 2>&1 || { echo >&2 "[ERROR] Flutter not installed."; exit 1; } + @[ -n "$$ANDROID_SDK_ROOT$$ANDROID_HOME" ] || { echo >&2 "[ERROR] ANDROID_SDK_ROOT is not set (Android SDK/NDK required)."; exit 1; } + @cd scripts && bash prebuild.sh + @cd scripts && echo yes | ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p android -a $(APP_NAME) -d -s + @$(FLUTTER) pub get + @$(FLUTTER) build apk --split-per-abi --release + +# iOS: same steps as the "build-ios" CI job (device build, no code signing). Run on macOS with Xcode. +build-ios: check-sdk-macos ## Build iOS Runner.app without code signing (same steps as CI) + @command -v flutter >/dev/null 2>&1 || { echo >&2 "[ERROR] Flutter not installed."; exit 1; } + @cd scripts && bash prebuild.sh + @cd scripts && echo yes | ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p ios -a $(APP_NAME) -d -s + @$(FLUTTER) pub get + @$(FLUTTER) build ios --release --no-codesign diff --git a/README.md b/README.md index 70bf3f836f..1977ed7fae 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,31 @@ Highlights include: ## Building You can look at the [build instructions](docs/building.md) for more details. + +### macOS with Nix or direnv + +Use one shared toolchain path for both `nix develop` and `direnv`. + +1. Enable direnv for this repo (optional convenience): + - `.envrc` uses `use flake` + - run `direnv allow` +2. Enter the flake environment (if not using direnv): + - `nix develop` +3. Run build: + - `make build-macos` + +### macOS without Nix (Homebrew host setup) + +Use this only when you are not building through Nix/flake. + +1. Install host tools: + - `make bootstrap-macos` +2. Run build: + - Open a new terminal (or run `source ~/.zshrc`) so the Homebrew and + Flutter PATH entries are active. + - `make build-macos` + +The Homebrew bootstrap installs the native build tools and configures Rust +1.99.1 for Flutter's native-assets hooks. It replaces the former local +`crypto_plugins` macOS prebuild path; no submodule setup or manual native +plugin build is needed. diff --git a/bm/bench.sh b/bm/bench.sh new file mode 100755 index 0000000000..b53b2972da --- /dev/null +++ b/bm/bench.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# ============================================================================== +# bench.sh: Simple build benchmark for stack_wallet +# +# Usage: +# bash bm/bench.sh # cold build with prebuilt native assets (-d) +# bash bm/bench.sh --warm # warm rebuild (no clean) +# bash bm/bench.sh PREBUILTS=0 # cold, native assets compiled from source +# +# sccache is a machine setting, not a flag: run once, `make sccache-setup`, run again. +# The label records whether ~/.cargo/config.toml or CARGO_BUILD_RUSTC_WRAPPER selects sccache. +# +# Output: bm/results/.csv (one file per machine, all runs appended) +# ============================================================================== +set -euo pipefail + +banner() { if command -v toilet >/dev/null 2>&1; then toilet -f term --metal; else cat; fi; } + +HOST="$(hostname -s 2>/dev/null || echo unknown)" +OUTDIR="bm/results" +mkdir -p "$OUTDIR" +OUTFILE="${OUTDIR}/${HOST}.csv" + +WARM=0 +LABEL="" +MAKE_FLAGS="" + +# Parse args +while [ $# -gt 0 ]; do + case "$1" in + --warm) WARM=1; shift ;; + --label) LABEL="$2"; shift 2 ;; + *) MAKE_FLAGS="${MAKE_FLAGS:+${MAKE_FLAGS} }$1"; shift ;; + esac +done + +# Detect platform +case "$(uname -s)" in + Darwin) BUILD_TARGET="build-macos" ;; + Linux) BUILD_TARGET="build-linux" ;; + MINGW*|MSYS*|CYGWIN*) + echo "Git Bash on Windows detected. Benchmarking requires WSL2:" + echo " wsl bash bm/bench.sh" + exit 1 + ;; + *) echo "Unknown platform: $(uname -s)"; exit 1 ;; +esac + +# Git info +COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" + +# Auto-generate label only if not explicitly set +if [ -z "$LABEL" ]; then + LABEL=$([ $WARM -eq 1 ] && echo "warm" || echo "cold") + if echo "$MAKE_FLAGS" | grep -q "PREBUILTS=0"; then + LABEL="${LABEL}-source" + else + LABEL="${LABEL}-prebuilt" + fi + if grep -qs 'rustc-wrapper *= *"sccache"' "$HOME/.cargo/config.toml" 2>/dev/null \ + || [ "${CARGO_BUILD_RUSTC_WRAPPER:-}" = "sccache" ]; then + LABEL="${LABEL}-sccache" + else + LABEL="${LABEL}-nosccache" + fi +fi + +DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +ARCH="$(uname -m)" +OS_NAME="$(uname -s)" +OS_VER="$(uname -r)" +echo "Benchmark: ${LABEL}" | banner +echo "Host: $HOST" +echo "OS: $OS_NAME $OS_VER ($ARCH)" +echo "Branch: $BRANCH" +echo "Commit: $COMMIT" +echo "Target: ${BUILD_TARGET}" +echo "Flags: ${MAKE_FLAGS:-(none)}" +echo "" + +# Disk before +DISK_BEFORE=$(du -sk . 2>/dev/null | cut -f1 || echo 0) + +# Clean unless warm +if [ $WARM -eq 0 ]; then + echo "Cleaning" | banner + make clean 2>&1 | tail -1 +fi + +# Build +echo "Building: make ${BUILD_TARGET} ${MAKE_FLAGS} " | banner +sccache --zero-stats >/dev/null 2>&1 || true +START=$(date +%s) +# A failing build must still be timed and recorded, so errexit is off for the build and its status is taken from make. +set +e +make ${BUILD_TARGET} ${MAKE_FLAGS} 2>&1 | tail -5 +RC=${PIPESTATUS[0]} +set -e +END=$(date +%s) +WALL=$((END - START)) + +# Disk after +SCC_NOTE="" +if command -v sccache >/dev/null 2>&1; then + SCC_REQ=$(sccache --show-stats 2>/dev/null | awk '/^Compile requests executed/ {print $NF}' || echo 0) + SCC_HIT=$(sccache --show-stats 2>/dev/null | awk '/^Cache hits / {print $NF}' || echo 0) + [ -n "${SCC_REQ:-}" ] && [ "${SCC_REQ:-0}" != "0" ] && SCC_NOTE="sccache ${SCC_HIT:-0}/${SCC_REQ} hits" +fi +DISK_AFTER=$(du -sk . 2>/dev/null | cut -f1 || echo 0) +DISK_DELTA=$((DISK_AFTER - DISK_BEFORE)) + +# Write CSV header if new file +if [ ! -f "$OUTFILE" ]; then + echo "label,branch,commit,host,arch,os_name,os_ver,date,warm,flags,wall_sec,success,disk_total_kb,disk_delta_kb,comment" > "$OUTFILE" +fi + +echo "${LABEL},${BRANCH},${COMMIT},${HOST},${ARCH},${OS_NAME},${OS_VER},${DATE},${WARM},${MAKE_FLAGS:--},${WALL},$((RC == 0 ? 1 : 0)),${DISK_AFTER},${DISK_DELTA},${SCC_NOTE}" >> "$OUTFILE" + +echo "" +if [ $RC -eq 0 ]; then + MIN=$((WALL/60)); SEC=$((WALL%60)) + echo "✓ Build succeeded in ${WALL}s \(${MIN}m ${SEC}s\)" +else + echo "✗ Build FAILED after ${WALL}s" +fi +echo "Results appended: $OUTFILE" | banner + +# Show all runs +echo "" +echo "=== All results for $HOST ($ARCH $OS_NAME $OS_VER) ===" +printf "%-12s %-20s %-8s %-10s %4s %10s %8s %s\n" DATE MODE COMMIT FLAGS WARM WALL DISK COMMENT +printf '%s\n' '──────────────────────────────────────────────────────────────────────────────────────' +tail -n +2 "$OUTFILE" | while IFS=, read -r label branch commit host arch os_name os_ver date warm flags wall_sec success disk_total disk_delta comment; do + delta_mb=$((disk_delta / 1024)) + w=$((wall_sec)); m=$((w/60)); s=$((w%60)) + time_fmt="${m}m${s}s" + warm_label=$([ "$warm" = "1" ] && echo "yes" || echo "no") + [ "$success" = "1" ] && ok="✓" || ok="✗" + printf "%-12s %-20s %-8s %-10s %-4s %10s %8s %s\n" "${date:0:10}" "$label" "${commit:0:7}" "${flags:--}" "$warm_label" "$time_fmt" "$ok ${delta_mb}MB" "$comment" +done + +# Report the build's own exit status to the caller. +exit "$RC" diff --git a/bm/collect.sh b/bm/collect.sh new file mode 100755 index 0000000000..e1a8b066ec --- /dev/null +++ b/bm/collect.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# ============================================================================== +# collect.sh: Measure disk usage and artifact sizes independent of benchmarks +# Usage: ./bm/collect.sh [--outdir DIR] [--baseline NAME] +# +# Outputs a CSV with per-directory and per-artifact sizes. +# ============================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# The active benchmark path is bench.sh -> compare.py -> render.sh. Keep this +# collector self-contained so it can snapshot the native-assets layout without +# relying on the retired targets.sh configuration. +BUILD_DIRS=( + .dart_tool + build + .pub-cache +) +ARTIFACT_DIRS=( + build/macos/Build/Products/Release + build/linux/x64/release/bundle + build/windows/x64/runner/Release + build/app/outputs/flutter-apk +) + +detect_platform() { + case "$(uname -s)" in + Darwin) echo "macos" ;; + Linux) echo "linux" ;; + MINGW*|MSYS*|CYGWIN*) echo "windows" ;; + *) echo "unknown" ;; + esac +} + +OUTDIR="${BM_OUTDIR:-$REPO_ROOT/bm/results}" +BASELINE="${BM_BASELINE:-current}" +TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +PLATFORM="$(detect_platform)" +HOSTNAME="$(hostname -s 2>/dev/null || echo "unknown")" + +main() { + while [ $# -gt 0 ]; do + case "$1" in + --outdir) OUTDIR="$2"; shift ;; + --baseline) BASELINE="$2"; shift ;; + *) echo "Unknown: $1"; exit 1 ;; + esac + shift + done + + mkdir -p "$OUTDIR" + local csv="$OUTDIR/sizes_${BASELINE}.csv" + + echo "=== Collecting sizes ($BASELINE, $PLATFORM, $TIMESTAMP) ===" >&2 + + # Header + { + printf "baseline,platform,hostname,date,metric,value_kb,note\n" + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},git_commit,$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)," + } > "$csv" + + # Disk directories + for d in "${BUILD_DIRS[@]}"; do + local path="$REPO_ROOT/$d" + local label="disk_$(echo "$d" | tr '/.' '_')" + if [ -d "$path" ] || [ -f "$path" ]; then + local sz + sz="$(du -sk "$path" 2>/dev/null | cut -f1 || echo 0)" + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},${label},${sz}," >> "$csv" + else + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},${label},0,missing" >> "$csv" + fi + done + + # Artifacts + for a in "${ARTIFACT_DIRS[@]}"; do + local path="$REPO_ROOT/$a" + local label="artifact_$(echo "$a" | tr '/.-' '_')" + if [ -e "$path" ]; then + local sz + sz="$(du -sk "$path" 2>/dev/null | cut -f1 || echo 0)" + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},${label},${sz}," >> "$csv" + else + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},${label},0,missing" >> "$csv" + fi + done + + # Total project + local total + total="$(du -sk "$REPO_ROOT" 2>/dev/null | cut -f1 || echo 0)" + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},total_project,${total}," >> "$csv" + + # Native-assets Rust target directories aggregate. Flutter's hooks keep + # these under .dart_tool instead of the deleted crypto_plugins tree. + local rust_total=0 + while IFS= read -r target_dir; do + local sz + sz="$(du -sk "$target_dir" 2>/dev/null | cut -f1 || echo 0)" + rust_total=$((rust_total + sz)) + done < <(find "$REPO_ROOT/.dart_tool/hooks_runner/shared" \ + -type d -path '*/build/*/target' 2>/dev/null) + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},rust_targets_aggregate,${rust_total}," >> "$csv" + + # Toolchain sizes + for tc_dir in .rustup-home .cargo-home; do + if [ -d "$REPO_ROOT/$tc_dir" ]; then + local tc_label="toolchain_${tc_dir#.}" + local tc_sz + tc_sz="$(du -sk "$REPO_ROOT/$tc_dir" 2>/dev/null | cut -f1 || echo 0)" + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},${tc_label},${tc_sz},installed_toolchains" >> "$csv" + + # Breakdown: list each toolchain + if [ "$tc_dir" = ".rustup-home" ] && [ -d "$REPO_ROOT/$tc_dir/toolchains" ]; then + for tc in "$REPO_ROOT/$tc_dir/toolchains"/*/; do + local tc_name + tc_name="$(basename "$tc")" + local tc_sz + tc_sz="$(du -sk "$tc" 2>/dev/null | cut -f1 || echo 0)" + echo "${BASELINE},${PLATFORM},${HOSTNAME},${TIMESTAMP},rustup_toolchain_${tc_name},${tc_sz},installed" >> "$csv" + done + fi + fi + done + + echo "--- Sizes written to $csv ---" >&2 +} + +main "$@" diff --git a/bm/compare.py b/bm/compare.py new file mode 100755 index 0000000000..d43462502d --- /dev/null +++ b/bm/compare.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +compare.py: Render benchmark results from bm/results/.csv files. + +Usage: + python3 bm/compare.py bm/results/ # terminal report + python3 bm/compare.py bm/results/ > report.md # markdown for bm/render.sh +""" + +import csv, sys, os +from collections import defaultdict +from pathlib import Path + +PIPE = "│" + +def load_results(results_dir): + rows = [] + for fpath in sorted(Path(results_dir).glob("*.csv")): + if fpath.name.startswith("sizes_"): + continue + try: + with open(fpath) as f: + reader = csv.DictReader(f) + for r in reader: + if r.get("success","0") in ("1", "True"): + rows.append(r) + except Exception as e: + print(f" skip {fpath.name}: {e}", file=sys.stderr) + return rows + + + +def wall_fmt(s): + """Format seconds to human readable.""" + try: + s = int(float(s)) + m, sec = divmod(s, 60) + if m >= 60: + h, m = divmod(m, 60) + return f"{h}h{m:02d}m{sec:02d}s" + return f"{m}m{sec:02d}s" + except: + return str(s) + +def wall_s(s): + """Parse string to int seconds.""" + try: return int(float(s)) + except: return 0 + +def mb(kb_str): + try: return int(float(kb_str)) // 1024 + except: return 0 + +def hr_label(label): + """Human readable label.""" + l = label.lower() + if l == "cold": return "Cold build" + if l == "warm": return "Warm rebuild" + parts = l.split("-") + if parts[0] in ("cold", "warm") and len(parts) == 3: + mode = {"prebuilt": "prebuilt assets", "source": "from source"}.get(parts[1], parts[1]) + cache = "no sccache" if parts[2] == "nosccache" else parts[2] + return f"{'Cold' if parts[0] == 'cold' else 'Warm'} ({mode}, {cache})" + return label + +def render(rows, fmt="terminal"): + if not rows: + print("No successful benchmark results found.", file=sys.stderr) + return + + # Group by host then label + by_host = defaultdict(list) + for r in rows: + by_host[r.get("host","unknown")].append(r) + + hdr = ["Date", "Mode", "Commit", "Arch", "OS", "Warm", "Flags", "Wall Time", "Disk Δ", "Comment"] + + for host, host_rows in sorted(by_host.items()): + if fmt == "markdown": + print(f"\n## {host}\n") + else: + print(f"\n{'═'*80}") + print(f" {host}") + print(f"{'═'*80}") + + # Sort: cold first, then by label + host_rows.sort(key=lambda r: (r.get("warm","0"), r.get("label",""))) + + if fmt == "markdown": + print(f"| {' | '.join(hdr)} |") + # Right-align Wall Time column (index 7) for easier comparison + aligns = [":---" if h not in ("Wall Time", "Disk Δ") else "---:" for h in hdr] + print(f"|{'|'.join(aligns)}|") + else: + # Terminal table with rich/box drawing + widths = [max(len(h), 18) for h in hdr] + # Print header + print(PIPE + PIPE.join(h.center(w) for h,w in zip(hdr,widths)) + PIPE) + print(PIPE + PIPE.join("─"*w for w in widths) + PIPE) + + for r in host_rows: + date = (r.get("date","")[:10]).strip() + label = hr_label(r.get("label","")) + commit = r.get("commit","")[:7] + arch = r.get("arch","") + os_str = f"{r.get('os_name','')} {r.get('os_ver','')}" + warm = "yes" if r.get("warm","0") == "1" else "no" + flags = r.get("flags","-").replace("SCCACHE=1,","").replace("SCCACHE=0,","") or "-" + wall = wall_fmt(r.get("wall_sec","")) + disk = f"{mb(r.get('disk_delta_kb',''))}MB" + comment = r.get("comment","") or "" + + cols = [date, label, commit, arch, os_str, warm, flags, wall, disk, comment] + + if fmt == "markdown": + print(f"| {' | '.join(cols)} |") + else: + print(PIPE + PIPE.join(c.center(w) for c,w in zip(cols,widths)) + PIPE) + +def compare(dir1, dir2=None): + """Compare two result directories.""" + rows1 = load_results(dir1) + rows2 = load_results(dir2) if dir2 else [] + all_rows = rows1 + rows2 + + if not all_rows: + print("No benchmark results found.", file=sys.stderr) + return + + # Mark format based on output + is_pipe = not sys.stdout.isatty() + fmt = "markdown" if is_pipe else "terminal" + + render(all_rows, fmt) + + if dir2 and rows1 and rows2: + print(f"\n{'═'*80}") + print(f" Delta: {os.path.basename(dir1)} → {os.path.basename(dir2)}") + print(f"{'═'*80}") + # Pair measurements by label and host; if a pair was measured several times, the latest row counts. + latest1 = {(r["label"], r.get("host", "unknown")): r for r in rows1} + latest2 = {(r["label"], r.get("host", "unknown")): r for r in rows2} + for key in sorted(latest1.keys() & latest2.keys()): + label, host = key + t1 = wall_s(latest1[key]["wall_sec"]) + t2 = wall_s(latest2[key]["wall_sec"]) + delta = t2 - t1 + pct = (delta/t1)*100 if t1 else 0 + sign = "+" if delta > 0 else "" + print(f" {hr_label(label):25s} {host:12s} {wall_fmt(t1)} → {wall_fmt(t2)} ({sign}{pct:.0f}%)") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [compare_dir]", file=sys.stderr) + print(f" {sys.argv[0]} bm/results/ # single report", file=sys.stderr) + print(f" {sys.argv[0]} old/ new/ # compare two dirs", file=sys.stderr) + print(f" {sys.argv[0]} bm/results/ > report.md # markdown for PDF", file=sys.stderr) + sys.exit(1) + + compare(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None) diff --git a/bm/render.sh b/bm/render.sh new file mode 100755 index 0000000000..3df37e33b0 --- /dev/null +++ b/bm/render.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# render.sh: Convert benchmark markdown report to HTML + PDF +# Usage: +# ./bm/compare.py bm/results/ > report.md +# ./bm/render.sh report.md [--open] +set -euo pipefail + +MD_FILE="${1:-}" +if [ -z "$MD_FILE" ] || [ ! -f "$MD_FILE" ]; then + echo "Usage: ./bm/render.sh [--open]" + exit 1 +fi + +OPEN=0 +[ "${2:-}" = "--open" ] && OPEN=1 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BASE="${MD_FILE%.md}" +HTML_FILE="${BASE}.html" +PDF_FILE="${BASE}.pdf" +CSS_FILE="$SCRIPT_DIR/report.css" + +# Convert to HTML +echo "=== Markdown → HTML ===" +{ + echo '' +} > /tmp/_bm_css.html + +pandoc "$MD_FILE" \ + --standalone \ + --metadata title="Stack Wallet: Build Benchmark Report" \ + --include-in-header /tmp/_bm_css.html \ + -o "$HTML_FILE" +rm -f /tmp/_bm_css.html +echo " → $HTML_FILE" + +# Convert to PDF +echo "=== HTML → PDF ===" +pdf_ok=0 + +if command -v weasyprint >/dev/null 2>&1; then + weasyprint "$HTML_FILE" "$PDF_FILE" 2>/dev/null && pdf_ok=1 +elif pip3 install --break-system-packages weasyprint 2>/dev/null; then + weasyprint "$HTML_FILE" "$PDF_FILE" 2>/dev/null && pdf_ok=1 +elif command -v wkhtmltopdf >/dev/null 2>&1; then + wkhtmltopdf --enable-local-file-access "$HTML_FILE" "$PDF_FILE" 2>/dev/null && pdf_ok=1 +elif [ "$(uname -s)" = "Darwin" ]; then + cupsfilter "$HTML_FILE" > "$PDF_FILE" 2>/dev/null && pdf_ok=1 || true +fi + +if [ "$pdf_ok" -eq 1 ] && [ -f "$PDF_FILE" ]; then + echo " → $PDF_FILE" +else + echo " ⚠ No PDF engine found. HTML ready: $HTML_FILE" +fi + +if [ "$OPEN" -eq 1 ]; then + open "$PDF_FILE" 2>/dev/null || open "$HTML_FILE" 2>/dev/null || true +fi + +echo "Done." diff --git a/bm/report.css b/bm/report.css new file mode 100644 index 0000000000..5e26a58650 --- /dev/null +++ b/bm/report.css @@ -0,0 +1,132 @@ +/* Stack Wallet Build Benchmark Report: Professional stylesheet */ +@page { + size: A4 landscape; + margin: 1.5cm 1.8cm; + @bottom-center { + content: "Page " counter(page) " of " counter(pages); + font-size: 7pt; + color: #888; + } +} + +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 10pt; + line-height: 1.55; + color: #222; + max-width: 100%; +} + +h1 { + font-size: 20pt; + font-weight: 700; + color: #1a1a1a; + border-bottom: 3px solid #2563eb; + padding-bottom: 8px; + margin-top: 0; + margin-bottom: 6px; +} + +h2 { + font-size: 13pt; + font-weight: 600; + color: #1a1a1a; + border-bottom: 1px solid #ccc; + padding-bottom: 4px; + margin-top: 28px; + margin-bottom: 10px; + page-break-after: avoid; +} + +h3 { + font-size: 11pt; + font-weight: 600; + margin-top: 16px; + margin-bottom: 6px; +} + +p { + margin: 4px 0 8px 0; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 10px 0 16px 0; + font-size: 9pt; + page-break-inside: avoid; +} + +thead { + display: table-header-group; +} + +th { + background: #2563eb; + color: white; + font-weight: 600; + padding: 4px 6px; + text-align: left; + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.2px; +} + +td { + border: 1px solid #ccc; + padding: 3px 6px; + text-align: left; + font-family: "SF Mono", "Menlo", "Monaco", "Cascadia Code", monospace; + font-size: 7.5pt; +} + +tr:nth-child(even) td { + background: #f8f9fb; +} + +code { + background: #f0f0f0; + padding: 1px 4px; + border-radius: 3px; + font-family: "SF Mono", "Menlo", "Monaco", monospace; + font-size: 8.5pt; +} + +strong { + color: #111; +} + +a { + color: #2563eb; + text-decoration: none; +} + +/* Improvement column - colored */ +td:last-child { + font-weight: 600; +} + +/* Phase breakdown table: alternating header */ +table:last-of-type th { + background: #1e293b; +} + +/* Key findings list spacing */ +h2 + ul li, h2 + p + ul li { + margin-bottom: 4px; +} + +/* First page has no header */ +@page :first { + margin-top: 2.5cm; +} + +/* Cover / header block */ +.report-meta { + background: #f8f9fb; + border-left: 4px solid #2563eb; + padding: 12px 16px; + margin: 16px 0; + font-size: 9pt; + border-radius: 2px; +} diff --git a/bm/run_all.sh b/bm/run_all.sh new file mode 100755 index 0000000000..098b7c9188 --- /dev/null +++ b/bm/run_all.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# ============================================================================== +# run_all.sh: Benchmark suite for `make build-macos`. +# +# Run order (cold = after `make clean`, warm = rebuild right after): +# A. Cold, prebuilt native assets (default) +# B. Warm, prebuilt native assets +# C. Cold, native assets compiled from source (PREBUILTS=0) +# D. Warm, native assets compiled from source +# +# sccache is a machine setting: run the suite once as is, then run +# `make sccache-setup` and run it again; the label records which one it was. +# ============================================================================== +set -euo pipefail + +HOST="$(hostname -s 2>/dev/null || echo unknown)" +echo "Stack Wallet full benchmark suite" +echo "Host: $HOST $(date)" +echo "" + +echo "A: cold, prebuilt" +make clean 2>/dev/null || true +bash bm/bench.sh +echo "" + +echo "B: warm, prebuilt" +bash bm/bench.sh --warm +echo "" + +echo "C: cold, from source" +make clean 2>/dev/null || true +bash bm/bench.sh PREBUILTS=0 +echo "" + +echo "D: warm, from source" +bash bm/bench.sh --warm PREBUILTS=0 +echo "" + +echo "Collecting disk and artifact sizes..." +bash bm/collect.sh || true +echo "" +echo "Done. Results: bm/results/$HOST.csv" +echo "View: python3 bm/compare.py bm/results/" +echo "PDF: python3 bm/compare.py bm/results/ > report.md && ./bm/render.sh report.md" diff --git a/docs/building.md b/docs/building.md index ab99a2e8e5..a88efdc02c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -47,7 +47,7 @@ sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2- ### Build dependencies Install basic dependencies ``` -sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm lld g++ gcc gperf python3-typogrify xsltproc valac gobject-introspection meson pipx libgcrypt20-dev libglib2.0-dev libsecret-1-dev +sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm lld g++ gcc gperf python3-typogrify xsltproc valac gobject-introspection meson pipx libgcrypt20-dev libglib2.0-dev libsecret-1-dev patchelf pipx install meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3.1.2 pygments==2.13.0 toml==0.10.2 typogrify==2.0.7 tomli==2.0.1 ``` @@ -180,7 +180,15 @@ flutter run android Note on Emulators: Only x86_64 emulators are supported, x86 emulators will not work. You should [configure KVM](https://help.ubuntu.com/community/KVM/Installation) for much better performance. -#### Linux +#### Windows + +On Windows the CI job `build-windows` builds natively on `windows-2022` (it does not use WSL2), and `make build-windows` runs the same steps from Git Bash: `scripts/prebuild.sh`, `scripts/build_app.sh -p windows -d -s`, `flutter pub get`, `dart run coinlib:build_windows`, `flutter build windows --release`. It needs Flutter, Go and a Visual Studio C++ toolchain on the machine. Where the user cannot create symbolic links (for example the GitHub-hosted runner), add `WINDOWS_COPY_ASSETS=1`; `link_assets.sh` then copies the asset directories instead of linking them. The WSL2 route described at the top of this guide still works. + +#### Android and iOS + +`make build-android` runs the steps of the `build-android` CI job (`prebuild.sh`, `build_app.sh -p android -d -s`, `flutter pub get`, `flutter build apk --split-per-abi --release`). It needs the Android SDK/NDK (`ANDROID_SDK_ROOT`) and Java; the CI image built from the `Dockerfile` (`docker build --target android`) has everything. The release build cannot be signed without the signing secrets, so `make android-dev-key` creates a throw-away debug key (`android/key.properties`, `android/dev-keystore.jks`, both git-ignored) when `android/key.properties` is missing. APKs built this way are only for local testing. `make build-ios` runs the `build-ios` CI steps on macOS (`flutter build ios --release --no-codesign`, a device build). + +### Linux Run the following commands or launch via Android Studio: ``` flutter pub get @@ -418,3 +426,64 @@ Run with `-v` or `--verbose` to see a more detailed error. Certain exceptions ( ## Tor To test Tor usage, run Stack Wallet from Android Studio. Click the Flutter DevTools icon in the Run tab (next to the Hot Reload and Hot Restart buttons) and navigate to the Network tab. Connections using Tor will show as `GET InternetAddress('127.0.0.1', IPv4) 101 ws`. Connections outside of Tor will show the destination address directly (although some Tor requests may also show the destination address directly, check the Headers take for *eg.* `{localPort: 59940, remoteAddress: 127.0.0.1, remotePort: 6725}`. `localPort` should match your Tor port. + +## Building with `make` (optional wrapper) + +The `Makefile` is a thin wrapper around the steps above. `make build-macos` runs +the same sequence as CI: `scripts/build_app.sh -p macos -a stack_wallet -s`, `flutter pub get`, +`scripts/prebuild.sh` and `flutter build macos --release`. `APP_NAME` selects the app +variant (`stack_wallet`, `stack_duo` or `campfire`). Run `make help` for all targets. + +``` +make build-macos VERSION=2.1.0 BUILD_NUM=210 # prebuilt native assets (-d), fast +make build-macos VERSION=2.1.0 BUILD_NUM=210 PREBUILTS=0 # compile native assets from source +``` + +### Toolchain on macOS + +Two ways to get Flutter 3.47.x, Rust 1.99.1, Go, CMake and CocoaPods. Both need a full Xcode.app. + +- **Homebrew:** `make bootstrap-macos` runs `scripts/install_macos_build_tools.sh`. It installs the + Homebrew packages, then uses an existing Flutter 3.47.2 or 3.47.4 or clones the pinned 3.47.2 tag + (override with `FLUTTER_VERSION` / `FLUTTER_DIR`). It installs the pinned Rust toolchain via rustup, + pins it for this checkout with `rustup override` (without changing your global default) and adds the + PATH entries to `~/.zshrc`. +- **Nix:** `nix develop` (or `direnv allow` with the included `.envrc`) provides the toolchain from + `flake.lock`. nixpkgs has no Flutter 3.47.2 (it goes from 3.47.0 to 3.47.4), so this shell uses + Flutter 3.47.4; Rust 1.99.1 is installed through rustup on first entry. Xcode itself always comes from the host. + +`make check-reqs-macos` verifies the result and `make diagnose-env-macos` prints what was found. + +### Xcode (optional download) + +`make bootstrap-xcode` only checks and selects an installed Xcode. To build with another installed Xcode without changing the global selection, run for example `DEVELOPER_DIR=/Applications/Xcode_27.0.app/Contents/Developer make build-macos`. If you have none, you can opt in to +`make bootstrap-xcode-download` (`scripts/download_xcode.sh`), which downloads a `.xip` (set `XCODE_XIP_URL` to use another mirror; the hash and signature are still checked) from +archive.org without an Apple ID. It checks the SHA-1 listed by archive.org and Apple's signature on +the archive, and aborts if either fails. Default is Xcode 27.1; use `XCODE_VERSION=27.0` when you need +the version that dnix pins to rebuild Apple libraries byte for byte. License acceptance and first-launch +setup only run with `ARGS=-y`. This route is not used in CI. Prefer the Mac App Store or developer.apple.com +if you can, since third-party redistribution of Xcode may conflict with Apple's terms. + +### Keep the build environment the same between runs + +Flutter reruns every native-asset build hook when `PATH` or any `NIX_*` variable differs from the previous build, even if no source changed. Measured on macOS: 33 to 40 s for a repeated build with an unchanged environment, 536 s after putting one extra directory in front of `PATH`, 156 to 164 s after adding a `NIX_*` variable or restoring `PATH`. So build always from the same shell setup (for example only through `make`, or only inside `nix develop`), and do not alternate between a terminal inside `nix develop` and an IDE without it. + +### Toolchain bill of materials + +`stack-bom.toml` lists the toolchain a build is expected to use (Flutter, Dart, Rust, Go, Xcode) and the git revisions of the pinned native plugins. `make verify-bom` compares the local machine and `pubspec.lock` against it and exits with an error on a mismatch (a different recommended Flutter or Xcode version is only a warning). After changing a plugin pin, run `dart tool/verify_bom.dart --update` to refresh the `[plugins]` section. + +### Optional: sccache for native-asset builds + +`make sccache-setup` writes `~/.cargo/config.toml` with `rustc-wrapper = "sccache"` (it never overwrites an +existing file). The build hooks only see a filtered environment (Flutter 3.47.x, and also the 3.50 beta tried so far), so +exporting `RUSTC_WRAPPER` does nothing in either: sccache saw no compile requests. Cargo still reads this file +through `HOME`, which works with both versions. (With the 3.50 beta the variable `CARGO_BUILD_RUSTC_WRAPPER` +also gets through.) Check `sccache --show-stats` +after a build. Measured on an Apple Silicon Mac for a from-source build of all native assets (one run each): +775 s without sccache, 665 s with a cold sccache, 534 s with a warm one. The prebuilt path (`-d`, the +`make build-macos` default) only replaces the tor, epiccash and mwc libraries, so it saved about one minute +in a cold build (12 vs. 13 minutes). + +### Linux + +`make build-linux` runs the Linux flow (`scripts/build_app.sh -p linux`, local libsecret and secp256k1 helper builds, `flutter build linux --release`, then `scripts/linux/prepare_release_bundle.py` so the bundle is safe to package). It needs `protoc`, Meson, Ninja, `patchelf`, `readelf` and `python3`; inside `nix develop` they are provided. Before building it writes `.cargo/config.toml` with `[env] PROTOC = ""` if the file does not exist (it is git-ignored). This is needed on NixOS, where a generic-Linux `protoc` shipped inside a crate cannot run and the exported variable does not reach the build hooks. Linux builds use a repo-local `.pub-cache` so cargo finds that file; other targets keep the normal per-user cache. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..57013d3b7e --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1789546076, + "narHash": "sha256-zVxLZiSnmaaPLwnhj7pwmqe3axBg/C6nG5JZsJMh2g4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..b36de7dfa4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,194 @@ +{ + description = "Stack Wallet Build Environment"; + + inputs = { + # Pinned to a specific nixpkgs rev for reproducible Flutter/Rust toolchain + # versions. Update via `nix flake lock --update-input nixpkgs` and re-test + # `make build-macos` and `make build-linux` before bumping. + nixpkgs.url = "github:NixOS/nixpkgs/b1b875982b17dabde9b4a37f3e229e74913e6db3"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + lib = pkgs.lib; + rustVersion = "1.99.1"; + + commonPackages = with pkgs; [ + flutter + dart + go + rustup + cmake + meson + ninja + pkg-config + gnumake + gnused + rsync + protobuf + (python3.withPackages (ps: with ps; [ + pip toml tomli jinja2 markdown markupsafe pygments typogrify + ])) + ]; + + linuxPackages = lib.optionals pkgs.stdenv.isLinux (with pkgs; [ + gtk3 glib openssl xz clang libgcrypt gobject-introspection + llvmPackages.libclang + llvmPackages.clang + opencv + sysprof + libsysprof-capture + patchelf + binutils + ]); + + macPackages = lib.optionals pkgs.stdenv.isDarwin (with pkgs; [ + cocoapods + libiconv + autoconf + automake + libtool + ]); + + in + { + devShells.default = pkgs.mkShell { + buildInputs = commonPackages ++ linuxPackages ++ macPackages; + + shellHook = '' + echo "===================================================" + echo "Stack Wallet Dev-Environment activated!" + echo "Target System: ${system}" + echo "===================================================" + + export APP_PROJECT_ROOT_DIR=$(pwd) + export PATH="$HOME/.cargo/bin:$PATH" + + # The standalone Dart in the Nix Flutter wrapper cannot find the Flutter SDK on its own, + # so `dart run`/`dart pub` in a Flutter project fail ("Flutter users should use flutter pub"). + export FLUTTER_ROOT="$(dirname "$(dirname "$(readlink -f "$(command -v flutter)")")")" + + # ========================================== + # RUST TOOLCHAIN AUTOMATION + # Host Rust toolchain; native-assets crates select their own pinned toolchain. + # ========================================== + if ! rustup toolchain list | grep -q "${rustVersion}"; then + echo "Initializing Rust toolchain (this happens only once)..." + rustup toolchain install --no-self-update ${rustVersion} + fi + + # Pin this checkout without replacing the user's global rustup default. + rustup override set ${rustVersion} --path "$PWD" >/dev/null 2>&1 || true + + if [[ "${system}" == *"darwin"* ]]; then + rustup target add aarch64-apple-darwin aarch64-apple-ios --toolchain ${rustVersion} + fi + + if ! command -v cargo-ndk >/dev/null 2>&1; then + echo "Installing optional Android Cargo tooling..." + cargo install cargo-ndk + fi + + # ========================================== + # LINUX (NixOS) SPECIFICS + # ========================================== + ${lib.optionalString pkgs.stdenv.isLinux '' + # echo "🐧 Linux detected: Patching shebangs for NixOS..." + export LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib" + export BINDGEN_EXTRA_CLANG_ARGS="-isystem ${pkgs.llvmPackages.libclang.lib}/lib/clang/${pkgs.llvmPackages.clang.version}/include" + export PROTOC="${pkgs.protobuf}/bin/protoc" + ''} + + # ========================================== + # MACOS XCODE SANDBOX ESCAPE + # ========================================== + ${lib.optionalString pkgs.stdenv.isDarwin '' + # Nix cannot provide Xcode itself (Apple's license forbids + # redistributing it), so this only works once it's installed + # separately -- run `make bootstrap-xcode` if this warns. + if [ ! -d /Applications/Xcode.app ]; then + echo "[WARN] /Applications/Xcode.app not found. Full Xcode (not just Command Line Tools) is required for macOS/iOS builds. Run: make bootstrap-xcode" + fi + # Respect whatever Xcode is actually active via `xcode-select` + # instead of forcing the default install location -- hardcoding + # this broke `sudo xcode-select --switch` to a differently + # named/located Xcode (e.g. testing a second version side by + # side), causing exactly the kind of toolchain/SDK mismatch + # this whole escape hatch exists to prevent. `xcode-select -p` + # itself just echoes back an already-set DEVELOPER_DIR (per its + # man page), and nixpkgs' own Darwin stdenv sets one earlier in + # this shellHook (to its vendored apple-sdk) -- so it must be + # unset first to actually query the real system-wide selection. + export DEVELOPER_DIR="$(env -u DEVELOPER_DIR xcode-select -p 2>/dev/null || echo /Applications/Xcode.app/Contents/Developer)" + export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) + export MACOSX_DEPLOYMENT_TARGET="12.0" + + # --- NIX C++ COMPILER OVERRIDE --- + export CC=/usr/bin/clang + export CXX=/usr/bin/clang++ + unset LD + export AR=/usr/bin/ar + export AS=/usr/bin/as + export NM=/usr/bin/nm + export RANLIB=/usr/bin/ranlib + export STRIP=/usr/bin/strip + + export BINDGEN_EXTRA_CLANG_ARGS="-isysroot $SDKROOT" + export PROTOC="${pkgs.protobuf}/bin/protoc" + + mkdir -p .nix-bin + ln -sf /usr/bin/clang .nix-bin/cc + ln -sf /usr/bin/clang++ .nix-bin/c++ + ln -sf /usr/bin/xcodebuild .nix-bin/xcodebuild + ln -sf /usr/bin/clang .nix-bin/clang + ln -sf /usr/bin/clang++ .nix-bin/clang++ + + # SMART LIPO & XCRUN WRAPPER + # lipo wrapper: the Nix store copy of FlutterMacOS.framework is + # read-only, so make its containing dir writable before handing + # off to the real lipo. + rm -f .nix-bin/lipo .nix-bin/xcrun + + cat > .nix-bin/lipo <<'LIPO_EOF' +#!/bin/bash +for arg in "$@"; do + if [[ "$arg" == *"FlutterMacOS.framework"* ]]; then + chmod -R u+w "$(dirname "$arg")" 2>/dev/null || true + fi +done +exec /usr/bin/lipo "$@" +LIPO_EOF + chmod +x .nix-bin/lipo + + # xcrun wrapper: `xcrun -f lipo` must resolve to the wrapper + # above rather than the real /usr/bin/lipo. Everything else + # passes through to the real xcrun, with deployment-target/SDK + # env vars pinned to macOS so a leaked iOS/tvOS/etc value from + # the outer environment doesn't leak in. + cat > .nix-bin/xcrun <<'XCRUN_EOF' +#!/bin/bash +if [ "$1" = "-f" ] && [ "$2" = "lipo" ]; then + echo "__NIX_BIN_DIR__/lipo" + exit 0 +fi + +unset IPHONEOS_DEPLOYMENT_TARGET TVOS_DEPLOYMENT_TARGET WATCHOS_DEPLOYMENT_TARGET +unset XROS_DEPLOYMENT_TARGET XR_DEPLOYMENT_TARGET VISIONOS_DEPLOYMENT_TARGET DRIVERKIT_DEPLOYMENT_TARGET +export MACOSX_DEPLOYMENT_TARGET="''${MACOSX_DEPLOYMENT_TARGET:-12.0}" +export SDKROOT="''${SDKROOT:-$(/usr/bin/xcrun --sdk macosx --show-sdk-path)}" +exec /usr/bin/xcrun "$@" +XCRUN_EOF + sed -i.bak "s|__NIX_BIN_DIR__|$PWD/.nix-bin|" .nix-bin/xcrun && rm -f .nix-bin/xcrun.bak + chmod +x .nix-bin/xcrun + + export PATH="$PWD/.nix-bin:$PATH" + ''} + + ''; + }; + } + ); +} diff --git a/scripts/app_config/shared/link_assets.sh b/scripts/app_config/shared/link_assets.sh index 9c7147e3ed..8513333970 100755 --- a/scripts/app_config/shared/link_assets.sh +++ b/scripts/app_config/shared/link_assets.sh @@ -15,14 +15,38 @@ ASSET_SOURCES_DIR="${APP_PROJECT_ROOT_DIR}/asset_sources" ASSETS_DIR="${APP_PROJECT_ROOT_DIR}/assets" +remove_asset_entry() { + local target="$1" + if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then + # rm -rf can follow a Windows junction into its target. Remove the link + # with cmd.exe first and only fall back for a real copied directory. + local win_path + if command -v cygpath >/dev/null 2>&1; then + win_path="$(cygpath -w "$target")" + elif command -v wslpath >/dev/null 2>&1; then + win_path="$(wslpath -w "$target")" + fi + if [[ -n "${win_path:-}" ]] && cmd.exe /c rmdir "$win_path" >/dev/null 2>&1; then + return 0 + fi + fi + rm -rf "$target" +} + # finally update symlinks for dirname in "default_themes" "icon" "lottie" "in_app_logo_icons" "svg"; do LINK_SOURCE_DIR="${ASSET_SOURCES_DIR}/${dirname}/${SELECT_ASSETS_DIR}" - rm -f "${ASSETS_DIR}/${dirname}" + # Remove the previous link or the real directory left by a WINDOWS_COPY_ASSETS run. + remove_asset_entry "${ASSETS_DIR}/${dirname}" - if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then + if [[ "${APP_BUILD_PLATFORM}" = 'windows' && "${WINDOWS_COPY_ASSETS:-0}" = '1' ]]; then + # Some Windows hosts cannot create symlinks (for example the GitHub-hosted + # runner user lacks SeCreateSymbolicLinkPrivilege). Copy instead. + mkdir -p "${ASSETS_DIR}/${dirname}" + cp -r "${LINK_SOURCE_DIR}/." "${ASSETS_DIR}/${dirname}/" + elif [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then if command -v cygpath >/dev/null 2>&1; then LINK_SOURCE_DIR_WIN_PATH_VERSION=$(cygpath -w "${LINK_SOURCE_DIR}") LINK_NAME_WIN_PATH_VERSION=$(cygpath -w "${ASSETS_DIR}") diff --git a/scripts/app_config/templates/isar_build.sh b/scripts/app_config/templates/isar_build.sh index d1d53d7f93..23406b218d 100644 --- a/scripts/app_config/templates/isar_build.sh +++ b/scripts/app_config/templates/isar_build.sh @@ -2,7 +2,7 @@ find_isar_core_lib() { local isar_core_path - isar_core_path=$(find "${HOME}/.pub-cache/git" -type d -path "*/isar_core_ffi" -print -quit 2>/dev/null) + isar_core_path=$(find "${PUB_CACHE:-${HOME}/.pub-cache}/git" -type d -path "*/isar_core_ffi" -print -quit 2>/dev/null) [[ -z "${isar_core_path}" ]] && return 1 echo "${isar_core_path}" } @@ -75,7 +75,7 @@ build_isar_source() { local isar_core_path isar_core_path=$(find_isar_core_lib) || { - echo "Error: could not locate isar_core_ffi inside ~/.pub-cache/git." + echo "Error: could not locate isar_core_ffi inside ${PUB_CACHE:-~/.pub-cache}/git (run flutter pub get first)." return 1 } diff --git a/scripts/download_xcode.sh b/scripts/download_xcode.sh new file mode 100755 index 0000000000..e670be9bb1 --- /dev/null +++ b/scripts/download_xcode.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# ============================================================================== +# scripts/download_xcode.sh +# +# Download and install Xcode from archive.org without an Apple ID, App Store +# login or the `xcodes` CLI. Opt-in only: `make bootstrap-xcode-download`. +# +# Integrity: the .xip is checked against the SHA-1 that archive.org lists for +# the item (detects corruption/tampering of the download), then Apple's +# signature on the .xip is verified with `pkgutil --check-signature` +# (authenticity). Both must pass. +# +# Notes: +# - Redistribution of Xcode by third parties may violate Apple's license +# terms and the item can be taken down at any time. Prefer the Mac App +# Store or developer.apple.com when you can. Not for CI. +# - dnix (the prebuilt/reproducible native libs) pins Xcode 27.0 for building +# Apple libraries from source and for `dart tool/verify_release.dart`. Use +# `-v 27.0` for that; 27.1 is fine for normal app builds with prebuilts. +# +# Usage: +# ./scripts/download_xcode.sh [-v ] [-d ] [-c ] [-y] [-n] +# +# Options: +# -v Xcode version (default: 27.1; supported: 27.1, 27.0, 16.2, 16.0) +# -d Target .app path (default: /Applications/Xcode_.app) +# -c Cache directory for the .xip (default: ~/.cache/xcode) +# -n Dry run: print what would happen, download nothing +# -y Non-interactive: skip the confirmation prompt, accept the +# Xcode license and run first-launch setup +# -h Show this help +# +# Works with the macOS system bash 3.2. +# ============================================================================== + +set -euo pipefail + +XCODE_VERSION="27.1" +TARGET_DIR="" +CACHE_DIR="${XCODE_CACHE_DIR:-$HOME/.cache/xcode}" +DRY_RUN=0 +ASSUME_YES=0 + +# Verified releases (archive.org metadata). +# Output: BUILD|SHA1|IA_ITEM|FILE_NAME +release_info() { + case "$1" in + 27.1) echo "27A9275|d8b1d16397e6f2faab34bd48b19692a3f7797b9d|xcode-27.1|Xcode_27.1.xip" ;; + 27.0) echo "27A266a|04a7d831229ac95577f09398d87501a1c6fb92ad|xcode-27.0|Xcode_27.xip" ;; + 16.2) echo "16C5032a|aa1a3f12d4217ab04ea4064f40e92399192e15f9|xcode-16.2|Xcode_16.2.xip" ;; + 16.0) echo "16A242d|71b15ff10826474491ebca3d36a6ca5964b9e1c5|xcode-16.0|Xcode_16.xip" ;; + *) return 1 ;; + esac +} +SUPPORTED_VERSIONS="27.1 27.0 16.2 16.0" + +usage() { + sed -n '2,/^# =====*$/p' "$0" | sed -e '1d' -e '$d' -e 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while getopts "v:d:c:nyh" opt; do + case "${opt}" in + v) XCODE_VERSION="$OPTARG" ;; + d) TARGET_DIR="$OPTARG" ;; + c) CACHE_DIR="$OPTARG" ;; + n) DRY_RUN=1 ;; + y) ASSUME_YES=1 ;; + h) usage 0 ;; + *) usage 1 ;; + esac +done + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "[ERROR] This script must be run on macOS." >&2 + exit 1 +fi + +if [[ "${CI:-}" == "true" || "${CI:-}" == "1" ]]; then + echo "[ERROR] Refusing to download Xcode from archive.org in CI." >&2 + exit 1 +fi + +if ! RELEASE="$(release_info "$XCODE_VERSION")"; then + echo "[ERROR] Unsupported Xcode version: '$XCODE_VERSION'. Supported: $SUPPORTED_VERSIONS" >&2 + exit 1 +fi +IFS="|" read -r BUILD_NUM EXPECTED_SHA1 IA_ITEM FILE_NAME <<< "$RELEASE" + +if [[ -z "$TARGET_DIR" ]]; then + TARGET_DIR="/Applications/Xcode_${XCODE_VERSION}.app" +fi +if [[ "$TARGET_DIR" != *.app || "$TARGET_DIR" == "/" ]]; then + echo "[ERROR] Target must be an .app path, got: $TARGET_DIR" >&2 + exit 1 +fi + +# A relative cache or target dir must not change meaning after the extraction cd below. +case "$CACHE_DIR" in + /*) ;; + *) CACHE_DIR="$PWD/$CACHE_DIR" ;; +esac +case "$TARGET_DIR" in + /*) ;; + *) TARGET_DIR="$PWD/$TARGET_DIR" ;; +esac + +# sudo is only needed when the target directory is not writable (for example /Applications). +SUDO="" +mkdir -p "$(dirname "$TARGET_DIR")" 2>/dev/null || true +if [[ ! -w "$(dirname "$TARGET_DIR")" ]]; then + SUDO="sudo" +fi + +# XCODE_XIP_URL can point to another mirror (the SHA-1 and the Apple signature are still checked). +DOWNLOAD_URL="${XCODE_XIP_URL:-https://archive.org/download/${IA_ITEM}/${FILE_NAME}}" +TORRENT_URL="https://archive.org/download/${IA_ITEM}/${IA_ITEM}_archive.torrent" +XIP_PATH="${CACHE_DIR}/${FILE_NAME}" +XIP_PART="${XIP_PATH}.part" + +finish_install() { + if [[ $ASSUME_YES -eq 1 ]]; then + if [[ "$(xcode-select -p 2>/dev/null || true)" != "${TARGET_DIR}/Contents/Developer" ]]; then + sudo xcode-select -s "${TARGET_DIR}/Contents/Developer" + fi + if ! xcodebuild -license check >/dev/null 2>&1; then + sudo xcodebuild -license accept + fi + if ! xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1; then + sudo xcodebuild -runFirstLaunch + fi + echo "[SUCCESS] Xcode ${XCODE_VERSION} (${BUILD_NUM}) installed and selected." + xcodebuild -version + else + echo "[SUCCESS] Installed ${TARGET_DIR}. Finish with:" + echo " sudo xcode-select -s \"${TARGET_DIR}/Contents/Developer\"" + echo " sudo xcodebuild -license # read and accept" + echo " sudo xcodebuild -runFirstLaunch" + fi +} + +echo "=================================================================" +echo " Xcode from archive.org (hash + signature verified)" +echo "=================================================================" +echo " Xcode version : ${XCODE_VERSION} (build ${BUILD_NUM})" +echo " Target : ${TARGET_DIR}" +echo " Source : ${DOWNLOAD_URL}" +echo " Expected SHA-1: ${EXPECTED_SHA1}" +echo " Cache : ${XIP_PATH}" +echo "=================================================================" + +# Already installed? +if [[ -d "$TARGET_DIR" ]]; then + CURRENT_BUILD="$("$TARGET_DIR/Contents/Developer/usr/bin/xcodebuild" -version 2>/dev/null | awk '/Build version/ {print $3}' || true)" + if [[ "$CURRENT_BUILD" == "$BUILD_NUM" ]]; then + echo "[OK] Xcode ${XCODE_VERSION} (${BUILD_NUM}) is already installed at ${TARGET_DIR}." + # With -y, finish selection/license/first-launch now instead of exiting + # before the setup the caller explicitly asked for. + finish_install + exit 0 + fi + echo "[WARN] ${TARGET_DIR} exists with build '${CURRENT_BUILD}', expected '${BUILD_NUM}'. It will be replaced." +fi + +if [[ $DRY_RUN -eq 1 ]]; then + echo "[DRY-RUN] Would download : ${DOWNLOAD_URL}" + echo "[DRY-RUN] Alternative : ${TORRENT_URL}" + echo "[DRY-RUN] Would verify : SHA-1 ${EXPECTED_SHA1}, then pkgutil --check-signature" + exit 0 +fi + +if [[ $ASSUME_YES -eq 0 ]]; then + echo "This downloads ~2 GB, needs ~20 GB free while extracting, and uses sudo only if ${TARGET_DIR} is not writable." + echo "Redistribution of Xcode by third parties may conflict with Apple's license terms." + read -r -p "Continue? (yes/no): " ANSWER + [[ "$ANSWER" == "yes" ]] || { echo "Aborted."; exit 1; } +fi + +mkdir -p "$CACHE_DIR" +FREE_KB="$(df -k "$CACHE_DIR" | awk 'NR==2 {print $4}')" +if [[ "$FREE_KB" -lt $((20 * 1024 * 1024)) ]]; then + echo "[ERROR] At least 20 GB free are required in ${CACHE_DIR}." >&2 + exit 1 +fi + +# 1. Resumable download. A verified archive is reused; a partial .part file is +# resumed with curl -C - instead of being treated as a complete archive. +if [[ -f "$XIP_PATH" ]]; then + CACHED_SHA1="$(shasum -a 1 "$XIP_PATH" | awk '{print $1}')" + if [[ "$CACHED_SHA1" == "$EXPECTED_SHA1" ]]; then + echo "Using cached archive: $XIP_PATH" + else + echo "[WARN] Cached archive does not match the expected SHA-1; downloading again." >&2 + rm -f "$XIP_PATH" + fi +fi +if [[ ! -f "$XIP_PATH" ]]; then + if [[ -f "$XIP_PART" ]]; then + echo "Resuming partial download: $XIP_PART" + else + echo "Downloading ${FILE_NAME} (resumable; torrent alternative: ${TORRENT_URL})..." + fi + if ! curl -fL -C - --retry 8 --retry-delay 5 --retry-all-errors --progress-bar -o "$XIP_PART" "$DOWNLOAD_URL"; then + echo "[WARN] Could not resume; restarting the download from scratch." >&2 + rm -f "$XIP_PART" + curl -fL --retry 8 --retry-delay 5 --retry-all-errors --progress-bar -o "$XIP_PART" "$DOWNLOAD_URL" + fi + PART_SHA1="$(shasum -a 1 "$XIP_PART" | awk '{print $1}')" + if [[ "$PART_SHA1" != "$EXPECTED_SHA1" ]]; then + echo "[ERROR] SHA-1 mismatch, the download is corrupt or altered." >&2 + echo " expected: $EXPECTED_SHA1" >&2 + echo " got : $PART_SHA1" >&2 + rm -f "$XIP_PART" + exit 1 + fi + mv "$XIP_PART" "$XIP_PATH" +fi + +# 2. Integrity: SHA-1 from archive.org metadata +echo "Verifying SHA-1..." +ACTUAL_SHA1="$(shasum -a 1 "$XIP_PATH" | awk '{print $1}')" +if [[ "$ACTUAL_SHA1" != "$EXPECTED_SHA1" ]]; then + echo "[ERROR] SHA-1 mismatch, the download is corrupt or altered." >&2 + echo " expected: $EXPECTED_SHA1" >&2 + echo " got : $ACTUAL_SHA1" >&2 + rm -f "$XIP_PATH" + exit 1 +fi +echo "[OK] SHA-1 matches." + +# 3. Authenticity: Apple's signature on the .xip +echo "Verifying Apple signature..." +if ! pkgutil --check-signature "$XIP_PATH"; then + echo "[ERROR] Apple signature check failed. Not installing." >&2 + exit 1 +fi +echo "[OK] Apple signature valid." + +# Remove the quarantine flag from the archive, otherwise it spreads to every file in Xcode.app and +# Gatekeeper scans them all on first use (15 to 25 minutes). +xattr -d com.apple.quarantine "$XIP_PATH" 2>/dev/null || true + +# 4. Extract (signature is checked again by xip itself) +EXTRACT_DIR="${CACHE_DIR}/extract_${BUILD_NUM}" +rm -rf "$EXTRACT_DIR" +mkdir -p "$EXTRACT_DIR" +echo "Extracting (5-10 minutes)..." +( + cd "$EXTRACT_DIR" + if command -v unxip >/dev/null 2>&1; then + unxip "$XIP_PATH" + else + /usr/bin/xip --expand "$XIP_PATH" + fi +) +if [[ ! -d "${EXTRACT_DIR}/Xcode.app" ]]; then + echo "[ERROR] Extraction failed: Xcode.app not found." >&2 + exit 1 +fi + +# 5. Install +echo "Installing to ${TARGET_DIR}${SUDO:+ (sudo)}..." +$SUDO rm -rf "$TARGET_DIR" +$SUDO mv "${EXTRACT_DIR}/Xcode.app" "$TARGET_DIR" +$SUDO xattr -dr com.apple.quarantine "$TARGET_DIR" 2>/dev/null || true +rm -rf "$EXTRACT_DIR" + +# 6. Select and first-launch only with -y; otherwise print the commands. +finish_install diff --git a/scripts/install_macos_build_tools.sh b/scripts/install_macos_build_tools.sh new file mode 100755 index 0000000000..ec78b1bbb5 --- /dev/null +++ b/scripts/install_macos_build_tools.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash + +# Safe to source (source script.sh) or execute directly (./script.sh) +if [ -n "${ZSH_VERSION:-}" ]; then + if [[ "${ZSH_EVAL_CONTEXT:-}" == *file* ]]; then + set -uo pipefail # sourced in zsh, don't -e + else + set -euo pipefail # executed in zsh + fi +elif [ -n "${BASH_VERSION:-}" ]; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + set -uo pipefail # sourced in bash, don't -e + else + set -euo pipefail # executed in bash + fi +else + set -euo pipefail # unknown shell +fi +die() { echo "$@" >&2; return 1 2>/dev/null || exit 1; } + +if [[ "$(uname -s)" != "Darwin" ]]; then + die "This installer is for macOS only." +fi + +# Homebrew may not be in PATH after a fresh install +if ! command -v brew >/dev/null 2>&1; then + if [[ -x /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [[ -x /usr/local/bin/brew ]]; then + eval "$(/usr/local/bin/brew shellenv)" + else + echo "Homebrew not found. Installing..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + if [[ -x /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [[ -x /usr/local/bin/brew ]]; then + eval "$(/usr/local/bin/brew shellenv)" + else + die "Homebrew installation failed. Install manually: https://brew.sh" + fi + fi +fi + +# rustup is keg-only in Homebrew (conflicts with rust), so it has to be added to PATH by hand. +# Called again after `brew install`: on a first install the directory does not exist before it. +add_rustup_to_path() { + local prefix + prefix="$(brew --prefix rustup 2>/dev/null || true)" + [[ -n "$prefix" && -d "$prefix/bin" && ":$PATH:" != *":$prefix/bin:"* ]] && export PATH="$prefix/bin:$PATH" + return 0 +} +add_rustup_to_path + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)" +bash "$SCRIPT_DIR/install_xcode.sh" + +echo "Installing Homebrew packages..." +# Do not upgrade formulae that are already installed: a build machine keeps the versions it was set up with. +export HOMEBREW_NO_INSTALL_UPGRADE=1 +brew install direnv rustup cmake meson ninja pkg-config gnu-sed cocoapods go protobuf cbindgen autoconf automake libtool sccache +add_rustup_to_path + +# Only used for the benchmark reports in bm/. A failure here (for example a link conflict with a +# copy installed another way) must not stop the toolchain setup. +brew install pandoc weasyprint toilet figlet || echo "[WARN] optional report tools were not fully installed; only bm/ report rendering needs them." + +# The Homebrew cask always installs the latest Flutter, but CI builds and tests +# one pinned release. Install that tag from source instead. +FLUTTER_VERSION="${FLUTTER_VERSION:-3.47.2}" +# Both the CI version and the version pinned by the Nix shell/BOM work. An +# explicit FLUTTER_VERSION only accepts that version. +if [[ -n "${FLUTTER_ACCEPTED_VERSIONS:-}" ]]; then + : +elif [[ "$FLUTTER_VERSION" == "3.47.2" ]]; then + FLUTTER_ACCEPTED_VERSIONS="3.47.2 3.47.4" +else + FLUTTER_ACCEPTED_VERSIONS="$FLUTTER_VERSION" +fi +FLUTTER_DIR="${FLUTTER_DIR:-$HOME/development/flutter}" +case "$FLUTTER_DIR" in + /*) ;; + *) FLUTTER_DIR="$PWD/$FLUTTER_DIR" ;; +esac +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +flutter_installed_version() { + "$1" --version --machine 2>/dev/null | sed -n 's/.*"frameworkVersion": *"\([^"]*\)".*/\1/p' | head -n 1 +} + +flutter_version_ok() { + local accepted + for accepted in $FLUTTER_ACCEPTED_VERSIONS; do + [[ "$1" == "$accepted" ]] && return 0 + done + return 1 +} + +FLUTTER_BIN="$(command -v flutter 2>/dev/null || true)" +[[ -z "$FLUTTER_BIN" && -x "$FLUTTER_DIR/bin/flutter" ]] && FLUTTER_BIN="$FLUTTER_DIR/bin/flutter" +CURRENT_FLUTTER="" +[[ -n "$FLUTTER_BIN" ]] && CURRENT_FLUTTER="$(flutter_installed_version "$FLUTTER_BIN")" +FLUTTER_PATH_TO_PERSIST="" + +if flutter_version_ok "$CURRENT_FLUTTER"; then + echo "Flutter $CURRENT_FLUTTER already installed ($FLUTTER_BIN)." + [[ "$FLUTTER_BIN" == "$FLUTTER_DIR/bin/flutter" ]] && FLUTTER_PATH_TO_PERSIST="$FLUTTER_DIR/bin" +else + [[ -n "$CURRENT_FLUTTER" ]] && echo "Found Flutter $CURRENT_FLUTTER at $FLUTTER_BIN, need $FLUTTER_VERSION." + if [[ -d "$FLUTTER_DIR/.git" ]]; then + echo "Switching $FLUTTER_DIR to Flutter $FLUTTER_VERSION..." + git -C "$FLUTTER_DIR" fetch --tags origin + git -C "$FLUTTER_DIR" checkout "$FLUTTER_VERSION" + elif [[ -e "$FLUTTER_DIR" ]]; then + die "$FLUTTER_DIR exists but is not a Flutter git checkout. Move it away or set FLUTTER_DIR." + else + echo "Cloning Flutter $FLUTTER_VERSION into $FLUTTER_DIR..." + mkdir -p "$(dirname "$FLUTTER_DIR")" + git clone --branch "$FLUTTER_VERSION" --depth 1 https://github.com/flutter/flutter.git "$FLUTTER_DIR" + fi + FLUTTER_BIN="$FLUTTER_DIR/bin/flutter" + FLUTTER_PATH_TO_PERSIST="$FLUTTER_DIR/bin" +fi + +# Only prepend the managed SDK when the selected Flutter is not already on PATH. +FLUTTER_BIN_DIR="$(dirname "$FLUTTER_BIN")" +case ":$PATH:" in + *":$FLUTTER_BIN_DIR:"*) ;; + *) export PATH="$FLUTTER_BIN_DIR:$PATH" ;; +esac + +if ! command -v rustup >/dev/null 2>&1; then + die "rustup not found in PATH. Check brew install." +fi + +RUST_VERSION="${RUST_VERSION:-1.99.1}" +echo "Ensuring Rust $RUST_VERSION toolchain..." +rustup toolchain install "$RUST_VERSION" +rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --toolchain "$RUST_VERSION" >/dev/null 2>&1 || true +# Pin this checkout instead of changing the user's global rustup default; the +# native-asset crates also carry their own rust-toolchain.toml. +rustup override set "$RUST_VERSION" --path "$REPO_ROOT" >/dev/null 2>&1 || true + +# Homebrew's rustup formula is keg-only and does not necessarily create the +# cargo/rustc shims in ~/.cargo/bin — `rustup toolchain install` alone may leave +# that directory absent, which means no `cargo` on PATH in this shell or any +# future one. Create the shims via rustup-init when they are missing. +if [ ! -x "$HOME/.cargo/bin/cargo" ]; then + echo "cargo shims missing in ~/.cargo/bin; creating them via rustup-init..." + if command -v rustup-init >/dev/null 2>&1; then + rustup-init -y --no-modify-path --default-toolchain "$RUST_VERSION" || true + else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --default-toolchain "$RUST_VERSION" || true + fi +fi + +# cargo/rustc live in ~/.cargo/bin after rustup installs a toolchain +export PATH="$HOME/.cargo/bin:$PATH" +source "$HOME/.cargo/env" 2>/dev/null || true + +if ! command -v cargo >/dev/null 2>&1; then + die "cargo still not on PATH after rustup setup. Run 'rustup-init -y' manually, then re-run this script." +fi + +echo "Verifying toolchain..." +if command -v flutter >/dev/null 2>&1; then + flutter --version +else + echo "flutter not found in PATH. Add $FLUTTER_DIR/bin to your shell profile." +fi + +if command -v dart >/dev/null 2>&1; then + dart --version +else + echo "dart not found in PATH. It should come with Flutter." +fi + +rustup --version +rustup run "$RUST_VERSION" rustc --version +rustup run "$RUST_VERSION" cargo --version +pod --version +go version +autoreconf --version | head -n 1 || true +aclocal --version | head -n 1 || true + +echo "" +echo "Done." + +# Persist PATH entries for future terminals. Create ~/.zshrc when missing: a +# vanilla macOS install ships without one, and this block used to skip silently +# in that case — leaving new shells unable to find cargo/rustup and +# making `source`ing this script the only way to get a usable environment. +ZSHRC="$HOME/.zshrc" +if [ ! -f "$ZSHRC" ]; then + touch "$ZSHRC" + echo "→ Created $ZSHRC (macOS ships without one)" +fi +BREW_PREFIX="$(brew --prefix)" +grep -qF 'brew shellenv' "$ZSHRC" 2>/dev/null || \ + printf 'eval "$(%s/bin/brew shellenv)"\n' "$BREW_PREFIX" >> "$ZSHRC" +if [[ -n "$FLUTTER_PATH_TO_PERSIST" ]]; then + grep -qF "$FLUTTER_PATH_TO_PERSIST" "$ZSHRC" 2>/dev/null || \ + printf 'export PATH="%s:$PATH"\n' "$FLUTTER_PATH_TO_PERSIST" >> "$ZSHRC" +fi +grep -qF '$HOME/.cargo/bin' "$ZSHRC" 2>/dev/null || \ + printf 'export PATH="%s/opt/rustup/bin:$HOME/.cargo/bin:$PATH"\n' "$BREW_PREFIX" >> "$ZSHRC" +echo "→ PATH entries ensured in $ZSHRC" +echo " Open a new terminal (or run: source $ZSHRC) before 'make build-macos'." diff --git a/scripts/install_xcode.sh b/scripts/install_xcode.sh new file mode 100755 index 0000000000..2d7d74d3d5 --- /dev/null +++ b/scripts/install_xcode.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Checks for full Xcode.app and points xcode-select at it. This is split out +# from install_macos_build_tools.sh because Xcode acquisition is orthogonal to +# whether the rest of the toolchain comes from Homebrew or Nix: neither can +# provide Xcode itself (Apple's license forbids redistributing it), so this +# script must run for both kinds of setups, not just the Homebrew one. +# +# Xcode itself is never auto-installed here: Apple's own download tooling +# (the `xcodes` CLI, `mas`) has proven unreliable in practice -- both hit +# hard failures (a broken Apple API returning 404 regardless of version, and +# `mas install` requiring an interactive sudo TTY) that still ended up +# needing a manual download anyway. Fail with clear instructions instead. +# +# Safe to source (source script.sh) or execute directly (./script.sh) +if [ -n "${ZSH_VERSION:-}" ]; then + if [[ "${ZSH_EVAL_CONTEXT:-}" == *file* ]]; then + set -uo pipefail # sourced in zsh, don't -e + else + set -euo pipefail # executed in zsh + fi +elif [ -n "${BASH_VERSION:-}" ]; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + set -uo pipefail # sourced in bash, don't -e + else + set -euo pipefail # executed in bash + fi +else + set -euo pipefail # unknown shell +fi +die() { echo "$@" >&2; return 1 2>/dev/null || exit 1; } + +if [[ "$(uname -s)" != "Darwin" ]]; then + die "This installer is for macOS only." +fi + +echo "Checking Xcode..." + +# Any full Xcode that is already selected stays selected, whatever its file name (Xcode.app, +# Xcode_27.1.app, Xcode-beta.app, ...) and wherever it lives. Only an unusable selection (none, or +# the Command Line Tools) is replaced by an Xcode found in $XCODE_APPS_DIR. +XCODE_APPS_DIR="${XCODE_APPS_DIR:-/Applications}" +SELECTED_DEV="$(xcode-select -p 2>/dev/null || true)" + +if [[ "$SELECTED_DEV" == /*.app/Contents/Developer && -x "$SELECTED_DEV/usr/bin/xcodebuild" ]]; then + echo "Xcode already configured: ${SELECTED_DEV%/Contents/Developer}" +else + if [[ -n "${XCODE_APP:-}" ]]; then + if [[ ! -x "$XCODE_APP/Contents/Developer/usr/bin/xcodebuild" ]]; then + die "[ERROR] XCODE_APP is not a full Xcode.app: $XCODE_APP" + fi + elif [[ -d "$XCODE_APPS_DIR/Xcode.app" ]]; then + XCODE_APP="$XCODE_APPS_DIR/Xcode.app" + else + # Prefer the newest versioned install; alphabetically Xcode_16.2.app comes + # before Xcode_27.1.app, and Xcodes.app is not Xcode. + XCODE_APP="$(find "$XCODE_APPS_DIR" -maxdepth 1 -type d -name 'Xcode*.app' ! -name 'Xcodes.app' -print 2>/dev/null | sort -V | tail -n 1 || true)" + fi + if [[ -z "$XCODE_APP" ]]; then + die "[ERROR] Full Xcode is required for macOS/iOS builds (Command Line Tools alone are not enough). Install it, then re-run this script: + - Mac App Store: search 'Xcode' and install, or run 'mas install 497799835' if you already have 'mas' set up + - Or download the .xip directly from https://developer.apple.com/download/all/?q=xcode" + fi + echo "Xcode found at $XCODE_APP. Setting xcode-select path..." + sudo xcode-select -s "$XCODE_APP/Contents/Developer" +fi + +# Only ask for sudo when the license or first-launch setup is actually pending; +# an already-configured Xcode should not trigger these on every bootstrap run. +if ! xcodebuild -license check >/dev/null 2>&1; then + echo "Accepting the Xcode license..." + sudo xcodebuild -license accept +fi +if ! xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1; then + echo "Running Xcode first-launch setup..." + sudo xcodebuild -runFirstLaunch +fi diff --git a/scripts/linux/build_secp256k1.sh b/scripts/linux/build_secp256k1.sh index b6037a3060..187489d72b 100755 --- a/scripts/linux/build_secp256k1.sh +++ b/scripts/linux/build_secp256k1.sh @@ -1,15 +1,33 @@ +#!/usr/bin/env bash +set -e + mkdir -p build cd build + if [ ! -d "secp256k1" ]; then git clone https://github.com/bitcoin-core/secp256k1 fi + cd secp256k1 git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c git reset --hard + rm -rf build -mkdir -p build && cd build +mkdir -p build +cd build cmake .. -DSECP256K1_ENABLE_MODULE_RECOVERY=ON cmake --build . + +SECP_SO="$(find lib -maxdepth 1 -type f -name 'libsecp256k1.so*' | sort | head -n1)" +if [ -z "$SECP_SO" ]; then + echo "[ERROR] libsecp256k1 shared library not found after build." + exit 1 +fi + +# Legacy location used by parts of the build pipeline. mkdir -p ../../../../../build -cp lib/libsecp256k1.so.2.*.* "../../../../../build/libsecp256k1.so" -cd ../../../ \ No newline at end of file +cp "$SECP_SO" ../../../../../build/libsecp256k1.so + +# Location expected by Flutter/CMake install step. +mkdir -p ../../../../../build/linux/x64/release/secp256k1/lib +cp "$SECP_SO" ../../../../../build/linux/x64/release/secp256k1/lib/libsecp256k1.so diff --git a/scripts/linux/build_secure_storage_deps.sh b/scripts/linux/build_secure_storage_deps.sh index e84572bcaa..c58cd80d0e 100755 --- a/scripts/linux/build_secure_storage_deps.sh +++ b/scripts/linux/build_secure_storage_deps.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ "${USE_SYSTEM_SECURE_STORAGE_DEPS:-0}" = "1" ]; then echo "USE_SYSTEM_SECURE_STORAGE_DEPS is set; skipping build of jsoncpp and libsecret (using system packages)" @@ -21,8 +21,8 @@ cd jsoncpp || exit 1 git checkout $JSONCPP_TAG mkdir -p build cd build || exit 1 -cmake -DCMAKE_BUILD_TYPE=release -DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=ON -DARCHIVE_INSTALL_DIR=. -G "Unix Makefiles" .. -make -j"$(nproc)" +cmake -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DCMAKE_BUILD_TYPE=release -DBUILD_STATIC_LIBS=ON -DBUILD_SHARED_LIBS=ON -DARCHIVE_INSTALL_DIR=. -G "Unix Makefiles" .. || exit 1 +make -j"$(nproc)" || exit 1 cd "$LINUX_DIRECTORY" || exit 1 # Build libSecret @@ -38,9 +38,30 @@ if ! [ -x "$(command -v meson)" ]; then echo 'Error: meson is not installed.' >&2 exit 1 fi -meson _build -Dmanpage=false -Dgtk_doc=false +# A second run finds _build already configured; "meson _build" would fail on that, so reconfigure instead. +if [ -f _build/meson-private/coredata.dat ]; then + meson setup --reconfigure _build -Dvapi=false -Dmanpage=false -Dgtk_doc=false || exit 1 +else + meson setup _build -Dvapi=false -Dmanpage=false -Dgtk_doc=false || exit 1 +fi if ! [ -x "$(command -v ninja)" ]; then echo 'Error: ninja is not installed.' >&2 exit 1 fi -ninja -C _build +ninja -C _build || exit 1 + +# Publish a local pkg-config file that points at the locally built libsecret. +# This avoids relying on distro-specific libsecret/glib pkg-config metadata. +mkdir -p "$LINUX_DIRECTORY/pc" +cat > "$LINUX_DIRECTORY/pc/libsecret-1.pc" <= 2.44, gio-2.0 >= 2.44, gio-unix-2.0 >= 2.44 -Requires.private: libgcrypt >= 1.2.2 -Libs: -L${libdir} -lsecret-1 diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index df9eadddb4..c7ff329859 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e cd -- "$(dirname -- "${BASH_SOURCE[0]}")" diff --git a/stack-bom.toml b/stack-bom.toml new file mode 100644 index 0000000000..ac8f87adfe --- /dev/null +++ b/stack-bom.toml @@ -0,0 +1,28 @@ +# Stack Wallet build bill of materials: the toolchain a build is expected to use. +# Checked by `make verify-bom` (dart tool/verify_bom.dart). Keep it small and human readable. +schema_version = 1 + +[toolchain] +flutter = "3.47.4" # recommended; macOS builds verified with 3.47.2 and 3.47.4 +flutter_min = "3.47.2" # what CI and pubspec.template.yaml use today +dart_prefix = "3.13" # Dart bundled with Flutter 3.47.x +rust = "1.99.1" # installed through rustup, pinned by the native plugins +go_min = "1.24" # flutter_mwebd + +[hooks] +hooks_runner = "1.5.0" # pinned by Flutter 3.47.x; CARGO_/RUSTUP_ env vars reach hooks only from 1.6.2 + +[macos] +xcode = "27.1" # recommended for app builds +xcode_build = "27A9275" +xcode_dnix = "27.0" # dnix needs this one to rebuild Apple libraries byte for byte +xcode_xip_sha1 = "d8b1d16397e6f2faab34bd48b19692a3f7797b9d" # archive.org item xcode-27.1, see scripts/download_xcode.sh + +[plugins] +# Generated by `dart tool/verify_bom.dart --update` from pubspec.lock. Do not edit by hand. +flutter_libepiccash = "d5cfb763fa62928a6e6265e1f4412738772151eb" +flutter_libmwc = "d8c5d5488cb00e21d0dc04ad306a65e2d54c4d3c" +flutter_libsparkmobile = "3fdcb21160a39c051a0e73d7ae04f987134ecd5f" +frostdart = "c8be6a0ea892d744adfacc8e97df891feca49707" +tor_ffi_plugin = "e30ed0d386a40ee4a999aeaf57aafd32921d601e" +xelis_wallet_flutter = "8630a2cf28947497ec67b7535ddf1fac4cedbaa8" diff --git a/tool/verify_bom.dart b/tool/verify_bom.dart new file mode 100644 index 0000000000..20c8f27817 --- /dev/null +++ b/tool/verify_bom.dart @@ -0,0 +1,250 @@ +// Checks the local toolchain and the pinned native plugins against +// stack-bom.toml. +// +// dart tool/verify_bom.dart check, exit 1 on a mismatch +// dart tool/verify_bom.dart --update rewrite the [plugins] section from pubspec.lock +// +// No packages needed, so it runs before `flutter pub get`. +import 'dart:convert'; +import 'dart:io'; + +const _bomFile = 'stack-bom.toml'; +const _pluginNames = [ + 'flutter_libepiccash', + 'flutter_libmwc', + 'flutter_libsparkmobile', + 'frostdart', + 'tor_ffi_plugin', + 'xelis_wallet_flutter', +]; + +int _failures = 0; +int _warnings = 0; + +void _ok(String m) => stdout.writeln('[OK] $m'); +void _warn(String m) { + _warnings++; + stdout.writeln('[WARN] $m'); +} + +void _fail(String m) { + _failures++; + stdout.writeln('[FAIL] $m'); +} + +/// Minimal TOML reader: `[section]` headers and `key = "value"` lines. +Map> _readBom(String text) { + final result = >{}; + var section = ''; + for (final raw in text.split('\n')) { + final line = raw.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + if (line.startsWith('[') && line.endsWith(']')) { + section = line.substring(1, line.length - 1); + result.putIfAbsent(section, () => {}); + continue; + } + final eq = line.indexOf('='); + if (eq < 0) continue; + final key = line.substring(0, eq).trim(); + var value = line.substring(eq + 1).trim(); + final hash = value.lastIndexOf(' #'); + if (hash >= 0 && value.startsWith('"') == false) { + value = value.substring(0, hash).trim(); + } + if (value.startsWith('"')) { + final end = value.indexOf('"', 1); + value = value.substring(1, end); + } + result.putIfAbsent(section, () => {})[key] = value; + } + return result; +} + +Future _run(String exe, List args) async { + try { + // runInShell lets Windows start flutter.bat. Only stdout is parsed: Flutter + // writes startup-lock warnings to stderr, and appending those to the JSON + // breaks --machine output. + final r = await Process.run(exe, args, runInShell: Platform.isWindows); + if (r.exitCode != 0) return null; + return '${r.stdout}'.trim(); + } on ProcessException { + return null; + } +} + +List _ver(String v) => + RegExp(r'\d+').allMatches(v).map((m) => int.parse(m.group(0)!)).toList(); + +int _cmp(String a, String b) { + final x = _ver(a), y = _ver(b); + for (var i = 0; i < 3; i++) { + final p = i < x.length ? x[i] : 0, q = i < y.length ? y[i] : 0; + if (p != q) return p.compareTo(q); + } + return 0; +} + +/// Reads the git refs of the pinned plugins from pubspec.lock. +Map _lockRefs() { + final lock = File('pubspec.lock'); + if (!lock.existsSync()) return {}; + final lines = lock.readAsLinesSync(); + final refs = {}; + for (var i = 0; i < lines.length; i++) { + final m = RegExp(r'^ (\w+):$').firstMatch(lines[i]); + if (m == null || !_pluginNames.contains(m.group(1))) continue; + for (var j = i + 1; j < lines.length && lines[j].startsWith(' '); j++) { + final r = RegExp(r'resolved-ref: "?([0-9a-f]{40})"?') + .firstMatch(lines[j]); + if (r != null) refs[m.group(1)!] = r.group(1)!; + } + } + return refs; +} + +Future _update() async { + final refs = _lockRefs(); + if (refs.isEmpty) { + stderr.writeln('pubspec.lock not found or has none of the plugins.'); + exit(2); + } + final text = File(_bomFile).readAsStringSync(); + final start = text.indexOf('[plugins]'); + final head = start < 0 + ? text.trimRight() + : text.substring(0, start).trimRight(); + final buf = StringBuffer(head) + ..writeln('\n') + ..writeln('[plugins]') + ..writeln( + '# Generated by `dart tool/verify_bom.dart --update` from pubspec.lock. Do not edit by hand.', + ); + for (final n in _pluginNames) { + if (refs.containsKey(n)) buf.writeln('$n = "${refs[n]}"'); + } + File(_bomFile).writeAsStringSync(buf.toString()); + stdout.writeln('Updated [plugins] in $_bomFile (${refs.length} entries).'); +} + +Future main(List args) async { + if (!File(_bomFile).existsSync()) { + stderr.writeln('Run from the repository root ($_bomFile not found).'); + exit(2); + } + if (args.contains('--update')) return _update(); + + final bom = _readBom(File(_bomFile).readAsStringSync()); + final tc = bom['toolchain'] ?? {}; + + // Flutter / Dart + final configuredFlutter = Platform.environment['FLUTTER']?.trim() ?? ''; + final fl = await _run( + configuredFlutter.isEmpty ? 'flutter' : configuredFlutter, + ['--version', '--machine'], + ); + if (fl == null) { + _fail('flutter not found in PATH'); + } else { + final start = fl.indexOf('{'); + final end = fl.lastIndexOf('}'); + if (start < 0 || end <= start) { + _fail('unexpected output from flutter --version --machine'); + } else { + try { + final info = + jsonDecode(fl.substring(start, end + 1)) as Map; + final v = info['frameworkVersion'] as String; + final d = info['dartSdkVersion'] as String; + if (_cmp(v, tc['flutter_min'] ?? '0') < 0) { + _fail('Flutter $v is older than the minimum ${tc['flutter_min']}'); + } else if (v != tc['flutter']) { + _warn( + 'Flutter $v (recommended ${tc['flutter']}, ' + 'minimum ${tc['flutter_min']})', + ); + } else { + _ok('Flutter $v'); + } + if (d.startsWith(tc['dart_prefix'] ?? '')) { + _ok('Dart $d'); + } else { + _fail('Dart $d does not match ${tc['dart_prefix']}.x'); + } + } on FormatException { + _fail('could not parse flutter --version --machine output'); + } + } + } + + // Rust + final list = await _run('rustup', ['toolchain', 'list']); + final rust = tc['rust'] ?? ''; + if (list == null) { + _fail('rustup not found in PATH'); + } else if (list.split('\n').any((l) => l.startsWith('$rust-'))) { + _ok('Rust $rust installed via rustup'); + } else { + _fail('Rust $rust is not installed (rustup toolchain install $rust)'); + } + + // Go + final go = await _run('go', ['version']); + final gm = go == null ? null : RegExp(r'go(\d+\.\d+(\.\d+)?)').firstMatch(go); + if (gm == null) { + _fail('go not found in PATH'); + } else if (_cmp(gm.group(1)!, tc['go_min'] ?? '0') < 0) { + _fail('Go ${gm.group(1)} is older than the minimum ${tc['go_min']}'); + } else { + _ok('Go ${gm.group(1)}'); + } + + // Xcode (macOS only) + if (Platform.isMacOS) { + final mac = bom['macos'] ?? {}; + final xc = await _run('xcodebuild', ['-version']); + final xm = xc == null ? null : RegExp(r'Xcode (\S+)').firstMatch(xc); + if (xm == null) { + _fail('Xcode not found (xcode-select -p)'); + } else if (xm.group(1) == mac['xcode']) { + _ok('Xcode ${xm.group(1)}'); + } else if (xm.group(1) == mac['xcode_dnix']) { + _warn( + 'Xcode ${xm.group(1)} is the version dnix pins; ' + '${mac['xcode']} is recommended for app builds', + ); + } else { + _warn('Xcode ${xm.group(1)} (recommended ${mac['xcode']})'); + } + } + + // Pinned plugins against pubspec.lock + final refs = _lockRefs(); + final pinned = bom['plugins'] ?? {}; + if (refs.isEmpty) { + _warn('pubspec.lock not found, plugin pins not checked'); + } else { + for (final n in _pluginNames) { + final ref = refs[n]; + final pin = pinned[n]; + if (ref == null) { + _fail( + '$n is missing from pubspec.lock (path dependency or not resolved)', + ); + } else if (pin == null) { + _fail('$n is missing from [plugins] in $_bomFile (run --update)'); + } else if (pin == ref) { + _ok('$n ${ref.substring(0, 7)}'); + } else { + _fail( + '$n is ${ref.substring(0, 7)} in pubspec.lock but ' + '${pin.substring(0, 7)} in $_bomFile (run --update)', + ); + } + } + } + + stdout.writeln('\n$_failures failure(s), $_warnings warning(s).'); + exit(_failures == 0 ? 0 : 1); +}