From f54d0ffdf1ac5b2a3e49ab86fc326ec49c6985c8 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:04:29 +0200 Subject: [PATCH 01/21] build: add Makefile-based macOS/Linux build, bootstrap and docs Adds a Makefile that wraps scripts/build_app.sh: - make build-macos runs the same steps as CI (build_app.sh, pub get, prebuild.sh, flutter build macos). PREBUILTS=0 compiles native assets from source (the generated pubspec.yaml is removed first so an earlier -d run does not stick). Produces the same app as upstream (Stack Wallet.app, com.cypherstack.stack_wallet). - make bootstrap-macos (Homebrew; pins Flutter 3.47.2), nix develop via flake.nix/.envrc (sets FLUTTER_ROOT so `dart run` works), make bootstrap-xcode, and an opt-in make bootstrap-xcode-download (archive.org; SHA-1 and Apple signature are both checked). - make sccache-setup (opt-in): sccache for native-asset hook builds via ~/.cargo/config.toml. - make build-linux; writes a git-ignored .cargo/config.toml with PROTOC so crates that ship a generic-Linux protoc build on NixOS. - bm/ benchmark scripts and MWC integration tests. Docs: new "Building with make" section in docs/building.md. Verified (one machine each, one run): - macOS 27.2 / Xcode 27.1: make build-macos from a clean checkout (946 s cold, 319.0 MB), PREBUILTS=0 (596 s), and inside nix develop (Flutter 3.47.4, 261 s). - Linux: NixOS x86_64, nix develop, Rust 1.99.1, make build-linux produced build/linux/x64/release/bundle/stack_wallet (291 MB). - Xcode 27.1 download from archive.org: SHA-1 and pkgutil signature check pass (install step not run). Not tested: Ubuntu, Windows, Android, iOS, bootstrap-macos on a fresh Mac. --- .envrc | 1 + .gitignore | 11 + Makefile | 269 ++++++ README.md | 28 + bm/bench.sh | 131 +++ bm/collect.sh | 130 +++ bm/compare.py | 160 +++ bm/render.sh | 61 ++ bm/report.css | 132 +++ bm/run_all.sh | 41 + docs/building.md | 50 + flake.lock | 61 ++ flake.nix | 190 ++++ integration_test/mwc_ffi_test.dart | 20 + integration_test/mwc_ffi_test_service.dart | 1021 ++++++++++++++++++++ integration_test/mwc_test_result.dart | 28 + scripts/app_config/templates/isar_build.sh | 4 +- scripts/build_app.sh | 18 +- scripts/download_xcode.sh | 215 +++++ scripts/install_macos_build_tools.sh | 159 +++ scripts/install_nixos_build_tools.sh | 67 ++ scripts/install_xcode.sh | 55 ++ scripts/linux/build_secp256k1.sh | 24 +- scripts/linux/build_secure_storage_deps.sh | 22 +- scripts/prebuild.sh | 2 +- 25 files changed, 2883 insertions(+), 17 deletions(-) create mode 100644 .envrc create mode 100644 Makefile create mode 100755 bm/bench.sh create mode 100755 bm/collect.sh create mode 100755 bm/compare.py create mode 100755 bm/render.sh create mode 100644 bm/report.css create mode 100755 bm/run_all.sh create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 integration_test/mwc_ffi_test.dart create mode 100644 integration_test/mwc_ffi_test_service.dart create mode 100644 integration_test/mwc_test_result.dart create mode 100755 scripts/download_xcode.sh create mode 100755 scripts/install_macos_build_tools.sh create mode 100755 scripts/install_nixos_build_tools.sh create mode 100755 scripts/install_xcode.sh 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/.gitignore b/.gitignore index 1b5b2b46cc..b854b54aeb 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,9 @@ lib/wl_gen/generated/ /assets/windows/mwebd.exe /tool/build +bm/results/ +/.cargo/config.toml + +# Nix dev shell: generated wrappers +.nix-bin/ +.direnv/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..04dafa78fa --- /dev/null +++ b/Makefile @@ -0,0 +1,269 @@ +# ============================================================================== +# Stack Wallet build helper (macOS). 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) +PUB_CACHE ?= $(APP_PROJECT_ROOT_DIR)/.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 +SKIP_NATIVE ?= 0 +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 +MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 + +export APP_PROJECT_ROOT_DIR +export PUB_CACHE + +.PHONY: help check-reqs check-reqs-macos check-sdk-macos bootstrap-macos bootstrap-xcode bootstrap-xcode-download sccache-setup cache-stats clean build-macos build-linux diagnose-env-macos test-mwc + +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 + @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 0; \ + fi + @bash scripts/install_macos_build_tools.sh + @rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --toolchain $(RUST_VERSION) >/dev/null 2>&1 || true +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." + +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 $(PUB_CACHE)/git/ 2>/dev/null || true + @find $(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 + +build-macos: check-reqs-macos check-sdk-macos ## Build macOS release (same steps as upstream CI; PREBUILTS=0 compiles native assets from source) + @# pubspec.yaml is generated; build_app.sh only regenerates it from the template when unchanged, + @# so a previous PREBUILTS setting would otherwise stick. + @rm -f pubspec.yaml + @echo "--- Configuring project (scripts/build_app.sh)..." + @echo yes | env $(MACOS_ENV_UNSET) scripts/build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p macos -a stack_wallet -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 + + +test-mwc: ## Run MWC FFI integration test on macOS (assumes prior `make build-macos`) + @# Flutter's first-launch helper rewrites MACOSX_DEPLOYMENT_TARGET=10.15; reassert 12.0. + @sed -i.bak -e "s/MACOSX_DEPLOYMENT_TARGET = 10\\.15;/MACOSX_DEPLOYMENT_TARGET = 12.0;/g" -e "s/MACOSX_DEPLOYMENT_TARGET = 11\\.0;/MACOSX_DEPLOYMENT_TARGET = 12.0;/g" macos/Runner.xcodeproj/project.pbxproj 2>/dev/null || true + @rm -f macos/Runner.xcodeproj/project.pbxproj.bak + @chmod -R u+w macos/Runner.xcodeproj macos 2>/dev/null || true + @env $(MACOS_ENV_UNSET) $(MACOS_ENV_SET) \ + $(FLUTTER) test integration_test/mwc_ffi_test.dart -d macos + +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: 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) + @# 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..." + @if [ ! -f lib/external_api_keys.dart ]; then \ + echo "[WARN] lib/external_api_keys.dart missing; recreating template."; \ + printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\n' > lib/external_api_keys.dart; \ + fi + @$(FLUTTER) pub get + @mkdir -p scripts/linux/pc + @printf '%s\n' \ + 'prefix=$(CURDIR)/scripts/linux/build/libsecret' \ + 'exec_prefix=$${prefix}' \ + 'libdir=$${prefix}/_build/libsecret' \ + 'includedir=$${prefix}' \ + '' \ + 'Name: libsecret-1' \ + 'Description: GObject bindings for Secret Service API' \ + 'Version: 0.21.4' \ + 'Libs: -L$${libdir} -lsecret-1' \ + 'Cflags: -I$${includedir} -I$${includedir}/_build' \ + > scripts/linux/pc/libsecret-1.pc + @if command -v podman >/dev/null 2>&1 || command -v docker >/dev/null 2>&1; then \ + : 'coinlib BuildKit git fetch fails on NixOS; scope the legacy-builder workaround here'; \ + DOCKER_BUILDKIT=0 $(FLUTTER) pub run coinlib:build_linux; \ + else \ + echo "[WARN] podman/docker not found; skipping coinlib:build_linux"; \ + fi + @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; } + @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" \ + PROTOC="$(PROTOC_PATH)" $(FLUTTER) build linux --release diff --git a/README.md b/README.md index 70bf3f836f..8ba586ed7f 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 setup's + Rust 1.99.1 selection is 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..6863c55ddc --- /dev/null +++ b/bm/bench.sh @@ -0,0 +1,131 @@ +#!/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) + +# 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 +START=$(date +%s) +make ${BUILD_TARGET} ${MAKE_FLAGS} 2>&1 | tail -5 +RC=$? +END=$(date +%s) +WALL=$((END - START)) + +# Disk after +DISK_AFTER=$(du -sk . 2>/dev/null | cut -f1) +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}," >> "$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 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..f600e2c75c --- /dev/null +++ b/bm/compare.py @@ -0,0 +1,160 @@ +#!/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, json +from collections import defaultdict +from pathlib import Path +from statistics import mean, stdev, median + +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}") + labels1 = {r["label"] for r in rows1} + labels2 = {r["label"] for r in rows2} + for label in sorted(labels1 & labels2): + t1 = wall_s(rows1[0]["wall_sec"]) + t2 = wall_s(rows2[0]["wall_sec"]) + delta = t2 - t1 + pct = (delta/t1)*100 if t1 else 0 + sign = "+" if delta > 0 else "" + print(f" {hr_label(label):25s} {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..b27d83256f --- /dev/null +++ b/bm/render.sh @@ -0,0 +1,61 @@ +#!/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 + +[ "$OPEN" -eq 1 ] && open "$PDF_FILE" 2>/dev/null || open "$HTML_FILE" 2>/dev/null || true + +echo "Done." \ No newline at end of file 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..49bd674107 --- /dev/null +++ b/bm/run_all.sh @@ -0,0 +1,41 @@ +#!/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 "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 a9b44a485c..d6efdf786a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -418,3 +418,53 @@ 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`. It does not change the app's +name or bundle ID. 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, clones the pinned Flutter tag (3.47.4, the same patch release as the Nix shell; CI and the + rest of this guide use 3.47.2; builds were verified with both; override with `FLUTTER_VERSION` / `FLUTTER_DIR`), + installs the pinned Rust toolchain via rustup 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. If you have none, you can opt in to +`make bootstrap-xcode-download` (`scripts/download_xcode.sh`), which downloads a `.xip` 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. + +### Optional: sccache for native-asset builds + +`make sccache-setup` writes `~/.cargo/config.toml` with `rustc-wrapper = "sccache"` (it never overwrites an +existing file). With Flutter 3.47.x the build hooks only see a filtered environment, so exporting +`RUSTC_WRAPPER` does nothing, but cargo still reads this file through `HOME`. 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`). It needs `protoc`, Meson and Ninja; 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. 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..6fa8289522 --- /dev/null +++ b/flake.nix @@ -0,0 +1,190 @@ +{ + 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; + + 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 + ]); + + 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 "1.99.1"; then + echo "Initializing Rust toolchain (this happens only once)..." + rustup toolchain install --no-self-update 1.99.1 + fi + + rustup default 1.99.1 + + if [[ "${system}" == *"darwin"* ]]; then + rustup target add aarch64-apple-darwin aarch64-apple-ios --toolchain 1.99.1 + 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/integration_test/mwc_ffi_test.dart b/integration_test/mwc_ffi_test.dart new file mode 100644 index 0000000000..50bcb3b074 --- /dev/null +++ b/integration_test/mwc_ffi_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'mwc_ffi_test_service.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('MWC FFI smoke', (tester) async { + await FFITestService.initialize(); + final ok = await FFITestService.runAllTests(); + expect( + ok, + isTrue, + reason: FFITestService.testResults + .where((r) => !r.passed) + .map((r) => '${r.name}: ${r.error}') + .join('\n'), + ); + }); +} diff --git a/integration_test/mwc_ffi_test_service.dart b/integration_test/mwc_ffi_test_service.dart new file mode 100644 index 0000000000..8d73a5c585 --- /dev/null +++ b/integration_test/mwc_ffi_test_service.dart @@ -0,0 +1,1021 @@ +import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_libmwc/lib.dart'; +import 'package:flutter_libmwc/mwc.dart' as lib_mwc; +import 'package:path_provider/path_provider.dart'; + +import 'mwc_test_result.dart'; + +/// Comprehensive FFI integration test service. +class FFITestService { + static final List _testResults = []; + static bool _isInitialized = false; + + /// Initialize the test framework. + static Future initialize() async { + if (_isInitialized) return; + + _logInfo('FFI Test Service initialized successfully'); + _isInitialized = true; + } + + /// Get all test results. + static List get testResults => List.unmodifiable(_testResults); + + /// Clear all test results. + static void clearResults() { + _testResults.clear(); + _logInfo('Test results cleared'); + } + + /// Run all FFI integration tests. + static Future runAllTests() async { + if (!_isInitialized) { + throw StateError('FFI Test Service not initialized'); + } + + clearResults(); + _logInfo('Starting comprehensive FFI integration test suite'); + + bool allPassed = true; + + // Phase 1: Environment and pre-flight validation. + allPassed &= await _runEnvironmentTests(); + + // Phase 2: Basic FFI functionality. + allPassed &= await _runBasicFFITests(); + + // Phase 3: Wallet management tests. + allPassed &= await _runWalletManagementTests(); + + // Phase 4: Transaction functionality tests. + allPassed &= await _runTransactionTests(); + + // Phase 5: Basic slatepack functionality tests. + allPassed &= await _runSlatepackTests(); + + // Phase 6: MWCMQS listener functionality tests. + allPassed &= await _runMWCMQSTests(); + + _logInfo( + 'Test suite completed. Overall result: ${allPassed ? "PASS" : "FAIL"}', + ); + return allPassed; + } + + /// Run environment validation tests. + static Future _runEnvironmentTests() async { + bool allPassed = true; + + // Test 1: Platform detection. + allPassed &= await _runTest( + 'Platform Detection', + 'Verify current platform is correctly detected', + () async { + final platform = Platform.operatingSystem; + final supportedPlatforms = [ + 'linux', + 'windows', + 'macos', + 'android', + 'ios', + ]; + + if (!supportedPlatforms.contains(platform)) { + throw TestException('Unsupported platform: $platform'); + } + + return 'Platform: $platform (supported)'; + }, + ); + + // Test 2: Library loading. + allPassed &= await _runTest( + 'Library Loading', + 'Verify native library can be loaded', + () async { + try { + final mnemonic = Libmwc.getMnemonic(); + if (mnemonic.isEmpty) { + throw TestException( + 'Library loaded but basic function returned empty result', + ); + } + return 'Library loaded successfully, basic function operational'; + } catch (e) { + throw TestException('Failed to load or call native library: $e'); + } + }, + ); + + return allPassed; + } + + /// Run basic FFI functionality tests. + static Future _runBasicFFITests() async { + bool allPassed = true; + + // Test 1: Mnemonic generation. + allPassed &= await _runTest( + 'Mnemonic Generation', + 'Test FFI mnemonic generation function', + () async { + final mnemonic = Libmwc.getMnemonic(); + final words = mnemonic.split(' '); + + if (words.length != 24) { + throw TestException( + 'Invalid mnemonic length: ${words.length} (expected 24)', + ); + } + + return 'Generated 24-word mnemonic successfully'; + }, + ); + + // Test 2: Address validation. + allPassed &= await _runTest( + 'Address Validation', + 'Test FFI address validation function', + () async { + // Test with known invalid address. + final invalidResult = Libmwc.validateSendAddress( + address: 'invalid_address', + ); + if (invalidResult) { + throw TestException('Invalid address incorrectly validated as valid'); + } + + return 'Address validation working correctly'; + }, + ); + + return allPassed; + } + + /// Run wallet management integration tests. + static Future _runWalletManagementTests() async { + bool allPassed = true; + + // Test 1: Wallet configuration validation. + allPassed &= await _runTest( + 'Wallet Configuration', + 'Test wallet configuration creation and validation', + () async { + final testConfig = await _getTestWalletConfig(); + if (testConfig.isEmpty) { + throw TestException('Failed to create test wallet configuration'); + } + + // Validate config contains required fields. + final configData = { + 'wallet_dir': '', + 'check_node_api_http_addr': '', + 'chain': '', + }; + for (final key in configData.keys) { + if (!testConfig.contains(key)) { + throw TestException('Missing required config field: $key'); + } + } + + return 'Test wallet configuration created and validated successfully'; + }, + ); + + // Test 2: Wallet initialization. + allPassed &= await _runTest( + 'Wallet Initialization', + 'Test new wallet creation via FFI', + () async { + final testMnemonic = Libmwc.getMnemonic(); + final testConfig = await _getTestWalletConfig(); + final testPassword = 'test_password_123'; + final walletName = + 'ffi_test_wallet_${DateTime.now().millisecondsSinceEpoch}'; + + try { + final result = await Libmwc.initializeNewWallet( + config: testConfig, + mnemonic: testMnemonic, + password: testPassword, + name: walletName, + ); + + if (result.toUpperCase().contains('ERROR')) { + throw TestException('Wallet initialization failed: $result'); + } + + return 'New wallet initialized successfully: $walletName'; + } catch (e) { + // Expected to potentially fail if wallet already exists or other issues. + if (e.toString().contains('already exists')) { + return 'Wallet initialization handled existing wallet correctly'; + } + rethrow; + } + }, + ); + + // Test 3: Wallet recovery. + allPassed &= await _runTest( + 'Wallet Recovery', + 'Test wallet recovery from mnemonic via FFI', + () async { + final testMnemonic = Libmwc.getMnemonic(); + final testConfig = await _getTestWalletConfig(); + final testPassword = 'recovery_test_123'; + final walletName = + 'ffi_recovery_test_${DateTime.now().millisecondsSinceEpoch}'; + + try { + await Libmwc.recoverWallet( + config: testConfig, + password: testPassword, + mnemonic: testMnemonic, + name: walletName, + ); + + return 'Wallet recovery from mnemonic completed successfully'; + } catch (e) { + // Expected to potentially fail in test environment. + if (e.toString().contains('directory') || + e.toString().contains('permission')) { + return 'Wallet recovery handled filesystem constraints correctly'; + } + throw TestException('Unexpected error in wallet recovery: $e'); + } + }, + ); + + // Test 4: Chain height retrieval. + allPassed &= await _runTest('Chain Height Query', 'Test chain height retrieval via FFI using remote MWC node', () async { + final testConfig = await _getTestWalletConfig(); + + try { + final height = await Libmwc.getChainHeight(config: testConfig); + + if (height < 0) { + throw TestException('Invalid chain height returned: $height'); + } + + // Mainnet should have a reasonable height (over 1 million blocks as of 2024). + if (height < 1000000) { + throw TestException( + 'Chain height seems too low for mainnet: $height', + ); + } + + return 'Chain height retrieved successfully: $height (mainnet)'; + } catch (e) { + final errorStr = e.toString(); + + // Handle specific error types with more detail. + if (errorStr.contains('FormatException') || + errorStr.contains('Invalid radix-10')) { + throw TestException( + 'Failed to parse chain height response: node may have returned an error message instead of height', + ); + } else if (errorStr.contains('connection') || + errorStr.contains('network') || + errorStr.contains('timeout')) { + throw TestException( + 'Network connection issue with remote node: ${errorStr.substring(0, 100)}...', + ); + } else if (errorStr.contains('Cannot m')) { + throw TestException( + 'Remote node connection failed - possibly network or SSL issue', + ); + } + + // Re-throw with more context. + throw TestException( + 'Chain height query failed: ${errorStr.substring(0, 100)}...', + ); + } + }); + + return allPassed; + } + + static Future _runTransactionTests() async { + bool allPassed = true; + + // Test 1: Transaction function availability. + allPassed &= await _runTest('Transaction Function Availability', 'Verify transaction functions are available via FFI', () async { + // Test that transaction functions exist and can be called. + // This validates the FFI bindings are working without requiring an actual wallet. + + try { + // First test: Validate address format function (should not panic). + final isValid = Libmwc.validateSendAddress(address: 'test@example.com'); + + // This should return false for a test address, but validates the function works. + if (isValid == true || isValid == false) { + return 'Transaction functions available: address validation working (result: $isValid)'; + } + + throw TestException('Address validation returned unexpected result'); + } catch (e) { + final errorStr = e.toString(); + + // Any error here indicates a problem with the FFI bindings themselves. + throw TestException( + 'Transaction function availability test failed: ${errorStr.substring(0, 100)}...', + ); + } + }); + + // Test 2: Transaction API Structure Validation. + allPassed &= await _runTest('Transaction API Structure', 'Validate transaction-related API structure and types', () async { + try { + // Test that we can create test configuration without errors. + final testConfig = await _getTestWalletConfig(); + + if (!testConfig.contains('wallet_dir') || + !testConfig.contains('check_node_api_http_addr') || + !testConfig.contains('chain')) { + throw TestException('Test configuration missing required fields'); + } + + // Test basic type validation. + const testAmount = 1000000; + const minConfirmations = 10; + + if (testAmount <= 0 || minConfirmations <= 0) { + throw TestException('Transaction parameter validation failed'); + } + + return 'Transaction API structure validation passed: config format and parameter types correct'; + } catch (e) { + throw TestException( + 'Transaction API structure test failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + // Test 3: Transaction Model Validation. + allPassed &= await _runTest('Transaction Model Validation', 'Validate transaction data models and type safety', () async { + try { + // Test transaction model structure by creating instances. + // This validates the Dart-side transaction types without calling FFI. + + final testAmount = 1500000; // 0.0015 MWC. + final testAddress = 'test_user@mwcmqs.example.com'; + final testNote = 'FFI integration test transaction'; + + // Validate parameter constraints. + if (testAmount <= 0) { + throw TestException('Transaction amount validation failed'); + } + + if (testAddress.isEmpty || !testAddress.contains('@')) { + throw TestException('Transaction address validation failed'); + } + + if (testNote.length > 500) { + throw TestException('Transaction note length validation failed'); + } + + return 'Transaction model validation passed: amount=$testAmount, address format validated, note length OK'; + } catch (e) { + throw TestException( + 'Transaction model validation failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + // Test 4: Transaction Error Code Validation. + allPassed &= await _runTest('Transaction Error Handling', 'Validate transaction error handling patterns', () async { + try { + // Test error message patterns that should be handled by transaction functions. + final expectedErrors = [ + 'wallet is not open', + 'WALLET_IS_NOT_OPEN', + 'insufficient funds', + 'invalid address', + 'network error', + 'connection timeout', + ]; + + // Validate we have error handling patterns for common issues. + for (final errorPattern in expectedErrors) { + if (errorPattern.isEmpty) { + throw TestException('Empty error pattern in validation list'); + } + } + + // Test amount boundary validation. + const minAmount = 1; + const maxAmount = 21000000 * 1000000000; // Max MWC supply in nanograms. + + if (minAmount >= maxAmount) { + throw TestException('Transaction amount boundary validation failed'); + } + + return 'Transaction error handling validation passed: ${expectedErrors.length} error patterns validated, amount boundaries correct'; + } catch (e) { + throw TestException( + 'Transaction error handling validation failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + return allPassed; + } + + /// Run basic slatepack functionality integration tests. + static Future _runSlatepackTests() async { + bool allPassed = true; + + // Test 1: Slatepack API Structure Validation. + allPassed &= await _runTest('Slatepack API Structure', 'Validate slatepack API structure and parameter types', () async { + try { + // Test basic slatepack parameter validation. + const testSlateJson = + '{"id":"test","tx":{"body":{"inputs":[],"outputs":[],"kernels":[]}}}'; + const testSlatepack = + 'BEGINSLATEPACK. test slatepack data .ENDSLATEPACK'; + const testRecipientAddress = 'test_user@mwcmqs.example.com'; + + // Validate parameter constraints for encoding. + if (testSlateJson.isEmpty) { + throw TestException('Slate JSON validation failed'); + } + + if (!testSlateJson.contains('id')) { + throw TestException('Slate JSON structure validation failed'); + } + + // Validate slatepack format structure. + if (!testSlatepack.contains('BEGINSLATEPACK') || + !testSlatepack.contains('ENDSLATEPACK')) { + throw TestException('Slatepack format validation failed'); + } + + // Validate address format. + if (testRecipientAddress.isEmpty || + !testRecipientAddress.contains('@')) { + throw TestException('Recipient address format validation failed'); + } + + return 'Slatepack API structure validation passed: slate format, slatepack format, and address format correct'; + } catch (e) { + throw TestException( + 'Slatepack API structure test failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + // Test 2: Slatepack Format Validation. + allPassed &= await _runTest('Slatepack Format Validation', 'Validate slatepack format patterns and structure', () async { + try { + // Test various slatepack format patterns. + final validFormats = [ + 'BEGINSLATEPACK. test data .ENDSLATEPACK', + 'BEGINSLATEPACK.\nencoded_data_here\n.ENDSLATEPACK', + 'BEGINSLATEPACK. VGVzdCBkYXRh .ENDSLATEPACK', // Base64-like. + ]; + + final invalidFormats = [ + 'INVALID FORMAT', + 'BEGINSLATEPACK without end', + 'missing begin ENDSLATEPACK', + '', + 'BEGINSLATE PACK. test .ENDSLATEPACK', // Wrong format. + ]; + + // Validate all valid formats pass basic structure check. + for (final format in validFormats) { + if (!format.contains('BEGINSLATEPACK') || + !format.contains('ENDSLATEPACK')) { + throw TestException( + 'Valid slatepack format failed validation: $format', + ); + } + } + + // Validate all invalid formats fail basic structure check. + for (final format in invalidFormats) { + if (format.contains('BEGINSLATEPACK') && + format.contains('ENDSLATEPACK')) { + throw TestException( + 'Invalid slatepack format passed validation: $format', + ); + } + } + + return 'Slatepack format validation passed: ${validFormats.length} valid formats recognized, ${invalidFormats.length} invalid formats rejected'; + } catch (e) { + throw TestException( + 'Slatepack format validation failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + // Test 3: Slatepack Roundtrip (compact, unencrypted). + allPassed &= await _runTest('Slatepack Roundtrip (compact)', 'Encode compact slate JSON to slatepack and decode back via FFI', () async { + try { + // Construct a minimal compact slate JSON. + // Use version 3 + compact_slate flag to satisfy current lib expectations. + const compactSlateJson = + '{\n' + ' "version_info": {\n' + ' "orig_version": 3,\n' + ' "version": 3,\n' + ' "block_header_version": 1\n' + ' },\n' + ' "id": "0436430c-2b02-624c-2032-570501212b00",\n' + ' "sta": "S1",\n' + ' "num_participants": 2,\n' + ' "amount": "1000000000",\n' + ' "fee": "1000000",\n' + ' "height": "0",\n' + ' "lock_height": "0",\n' + ' "ttl_cutoff_height": "1440",\n' + ' "payment_proof": null,\n' + ' "compact_slate": true,\n' + ' "participant_data": []\n' + '}'; + + // Encode to slatepack (unencrypted) — recipientAddress null. + final enc = await Libmwc.encodeSlatepack( + slateJson: compactSlateJson, + recipientAddress: null, + encrypt: false, + ); + + // Basic format assertions. + if (!enc.slatepack.contains('BEGINSLATEPACK') || + !enc.slatepack.contains('ENDSLATEPACK')) { + throw TestException('Encoded slatepack missing BEGIN/END markers'); + } + if (enc.wasEncrypted) { + throw TestException('Unencrypted encode reported as encrypted'); + } + + // Decode back to JSON. + final dec = await Libmwc.decodeSlatepack(slatepack: enc.slatepack); + final decoded = jsonDecode(dec.slateJson) as Map; + final decodedId = decoded['id'] as String?; + final versionInfo = decoded['version_info'] as Map?; + if (decodedId != '0436430c-2b02-624c-2032-570501212b00') { + throw TestException( + 'Decoded slate id mismatch or missing: $decodedId', + ); + } + if (versionInfo == null || versionInfo['version'] != 3) { + throw TestException('Decoded slate version is not v3'); + } + + // Verify encryption detection helper. + final isEncrypted = await Libmwc.isSlatepackEncrypted(enc.slatepack); + if (isEncrypted) { + throw TestException( + 'isSlatepackEncrypted returned true for unencrypted slatepack', + ); + } + + return 'Roundtrip succeeded; id=$decodedId; v4 compact; markers present; not encrypted'; + } catch (e) { + throw TestException('Slatepack roundtrip (compact) failed: $e'); + } + }); + + // Test 4: Slatepack Encryption Requirements (expected failure without wallet context). + allPassed &= await _runTest( + 'Slatepack Encryption Requirements', + 'Verify encrypted encode requires wallet context and recipient', + () async { + try { + bool threw = false; + try { + await Libmwc.encodeSlatepack( + slateJson: '{"id":"abc","version_info":{"version":3,"block_header_version":1}}', + recipientAddress: 'dummy@mwcmqs.mwc.mw', + encrypt: true, + wallet: null, // No wallet context in test environment + ); + } catch (e) { + threw = true; + final msg = e.toString(); + if (!msg.toLowerCase().contains('wallet') || + !msg.toLowerCase().contains('required')) { + throw TestException( + 'Unexpected error for encrypted encode without wallet: $msg', + ); + } + } + if (!threw) { + throw TestException( + 'Encrypted encode did not throw without wallet context', + ); + } + return 'Encrypted encode correctly requires wallet context'; + } catch (e) { + throw TestException('Encryption requirement validation failed: $e'); + } + }, + ); + + // Test 5: Slatepack Decode Invalid Format Handling. + allPassed &= await _runTest( + 'Slatepack Decode Invalid Format', + 'Decode invalid slatepack and validate graceful handling', + () async { + try { + final bogus = 'NOT_A_SLATEPACK'; + try { + final dec = await Libmwc.decodeSlatepack(slatepack: bogus); + // High-level API may not throw; validate empty slate JSON returned. + if (dec.slateJson.isNotEmpty) { + throw TestException( + 'Invalid slatepack returned non-empty slate JSON', + ); + } + return 'Invalid slatepack handled gracefully (empty slate JSON)'; + } catch (e) { + // If it throws, that's also acceptable as graceful failure. + final es = e.toString(); + final trunc = es.substring(0, es.length < 80 ? es.length : 80); + return 'Invalid slatepack decode threw as expected: $trunc...'; + } + } catch (e) { + throw TestException('Invalid format handling failed: $e'); + } + }, + ); + + // Test 3: Slatepack Encoding Parameter Validation. + allPassed &= await _runTest('Slatepack Encoding Parameters', 'Validate slatepack encoding parameter handling', () async { + try { + // Test encoding parameter validation without calling actual FFI. + final testCases = [ + { + 'name': 'basic slate', + 'slateJson': '{"id":"test-123","version_info":{"version":3,"block_header_version":1}}', + 'encrypt': false, + 'recipientAddress': null, + }, + { + 'name': 'encrypted slate', + 'slateJson': '{"id":"test-456","version_info":{"version":3,"block_header_version":1}}', + 'encrypt': true, + 'recipientAddress': 'user@mwcmqs.example.com', + }, + ]; + + for (final testCase in testCases) { + final slateJson = testCase['slateJson'] as String; + final encrypt = testCase['encrypt'] as bool; + final recipientAddress = testCase['recipientAddress'] as String?; + + // Validate slate JSON structure. + if (!slateJson.contains('id')) { + throw TestException( + 'Test case ${testCase['name']} missing required slate ID', + ); + } + + // Validate encryption parameters. + if (encrypt && + (recipientAddress == null || recipientAddress.isEmpty)) { + throw TestException( + 'Test case ${testCase['name']} encryption requires recipient address', + ); + } + + // Validate address format if provided. + if (recipientAddress != null && !recipientAddress.contains('@')) { + throw TestException( + 'Test case ${testCase['name']} invalid recipient address format', + ); + } + } + + return 'Slatepack encoding parameter validation passed: ${testCases.length} test cases validated'; + } catch (e) { + throw TestException( + 'Slatepack encoding parameter validation failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + // Test 4: Slatepack Decoding Parameter Validation. + allPassed &= await _runTest('Slatepack Decoding Parameters', 'Validate slatepack decoding parameter handling and response structure', () async { + try { + // Test decoding parameter validation and expected response structure. + final testSlatepack = 'BEGINSLATEPACK. dGVzdCBkYXRh .ENDSLATEPACK'; + + // Validate slatepack format. + if (!testSlatepack.contains('BEGINSLATEPACK') || + !testSlatepack.contains('ENDSLATEPACK')) { + throw TestException('Test slatepack format validation failed'); + } + + // Define expected decode response structure. + final expectedFields = [ + 'slate_json', + 'sender', // nullable. + 'recipient', // nullable. + ]; + + // Validate we have proper validation for expected response fields. + for (final field in expectedFields) { + if (field.isEmpty) { + throw TestException('Empty expected field in validation list'); + } + } + + // Test decoding error scenarios. + final errorTestCases = [ + {'input': '', 'expectedError': 'empty slatepack'}, + {'input': 'INVALID FORMAT', 'expectedError': 'invalid format'}, + { + 'input': 'BEGINSLATEPACK. corrupted_data .ENDSLATEPACK', + 'expectedError': 'decoding error', + }, + ]; + + // Validate error cases are properly structured. + for (final testCase in errorTestCases) { + final input = testCase['input'] as String; + final expectedError = testCase['expectedError'] as String; + + if (input.isEmpty && expectedError != 'empty slatepack') { + throw TestException( + 'Error test case mismatch: empty input should expect empty slatepack error', + ); + } + } + + return 'Slatepack decoding parameter validation passed: ${expectedFields.length} response fields validated, ${errorTestCases.length} error cases structured'; + } catch (e) { + throw TestException( + 'Slatepack decoding parameter validation failed: ${e.toString().substring(0, 100)}...', + ); + } + }); + + return allPassed; + } + + /// Run MWCMQS listener functionality integration tests. + static Future _runMWCMQSTests() async { + bool allPassed = true; + + // Test 1: MWCMQS API Function Availability. + allPassed &= await _runTest('MWCMQS API Function Availability', 'Verify MWCMQS FFI functions are properly loaded and accessible', () async { + try { + // Test that we can access the MWCMQS FFI functions through the native library. + // Try to call the FFI functions with test parameters to verify they're accessible. + + // Verify the mwcMqsListenerStart function exists by trying to call it. + try { + final testWallet = '[test_handle]'; + final testConfig = '{"test":"config"}'; + // This will likely fail but confirms the function is accessible. + lib_mwc.mwcMqsListenerStart(testWallet, testConfig); + return 'MWCMQS FFI functions verified: mwcMqsListenerStart accessible and callable'; + } catch (functionError) { + // Expected to fail with invalid parameters, but function should be accessible. + if (functionError.toString().contains('NoSuchMethodError')) { + throw TestException( + 'MWCMQS start function not available: ${functionError.toString()}', + ); + } + return 'MWCMQS FFI functions verified: mwcMqsListenerStart accessible (failed with expected error: ${functionError.toString().substring(0, 50)}...)'; + } + } catch (e) { + throw TestException( + 'MWCMQS API function availability test failed: ${e.toString()}', + ); + } + }); + + // Test 2: MWCMQS Listener Configuration. + allPassed &= await _runTest( + 'MWCMQS Listener Configuration', + 'Test MWCMQS configuration JSON creation and validation', + () async { + try { + // Create a MWCMQS configuration. + final mwcmqsConfig = jsonEncode({ + 'mwcmqs_domain': 'mqs.mwc.mw', + 'mwcmqs_port': 443, + 'mwcmqs_use_ssl': true, + }); + + // Validate configuration can be parsed. + final configData = jsonDecode(mwcmqsConfig); + if (configData['mwcmqs_domain'] != 'mqs.mwc.mw' || + configData['mwcmqs_port'] != 443 || + configData['mwcmqs_use_ssl'] != true) { + throw TestException('MWCMQS configuration validation failed'); + } + + return 'MWCMQS configuration created and validated: $mwcmqsConfig'; + } catch (e) { + throw TestException( + 'MWCMQS configuration test failed: ${e.toString()}', + ); + } + }, + ); + + // Test 3: MWCMQS Listener Start/Stop API. + allPassed &= await _runTest('MWCMQS Listener Start/Stop API', 'Test MWCMQS listener API accessibility and parameter validation', () async { + try { + // Test that the MWCMQS functions are accessible and accept parameters correctly. + // We'll test with invalid parameters to avoid network calls that might crash. + + // Test listener start function accessibility. + try { + // Use clearly invalid parameters that should trigger a controlled error. + final invalidWallet = 'invalid_wallet_handle'; + final invalidConfig = '{"invalid": "config"}'; + + // This should fail gracefully with a parameter error, not crash. + final result = lib_mwc.mwcMqsListenerStart( + invalidWallet, + invalidConfig, + ); + + // If we get a result without crashing, that's unexpected but good. + return 'MWCMQS listener API accessible: start function callable, returned pointer ${result.address}'; + } catch (apiError) { + // We expect this to fail with invalid parameters, which is good. + // Check that it's a controlled error, not a crash. + final errorString = apiError.toString(); + + if (errorString.contains('invalid') || + errorString.contains('parameter') || + errorString.contains('format') || + errorString.contains('parse') || + errorString.contains('wallet') || + errorString.contains('config')) { + return 'MWCMQS listener API validated: start function accessible and properly validates parameters (error: ${errorString.substring(0, 60)}...)'; + } + + // If it's some other error, that's still validation that the function exists. + return 'MWCMQS listener API accessible: start function exists and callable (failed with: ${errorString.substring(0, 60)}...)'; + } + } catch (e) { + throw TestException('MWCMQS listener API test failed: ${e.toString()}'); + } + }); + + // Test 4: MWCMQS High-Level API Integration. + allPassed &= await _runTest('MWCMQS High-Level API Integration', 'Test high-level ListenerManager API for MWCMQS functionality', () async { + try { + // Test that Libmwc MWCMQS functions are accessible. + // We can't check if methods are null, so we'll try to call them. + try { + // Try to access the methods - this will throw if they don't exist. + final startMethod = Libmwc.startMwcMqsListener; + final stopMethod = Libmwc.stopMwcMqsListener; + + // If we get here, methods exist. + } catch (methodError) { + throw TestException( + 'Libmwc MWCMQS methods not available: ${methodError.toString()}', + ); + } + + // Test high-level API call structure. + try { + // This should fail gracefully since we don't have a real wallet/server. + Libmwc.startMwcMqsListener( + wallet: '[test_wallet_handle]', + mwcmqsConfig: jsonEncode({ + 'mwcmqs_domain': 'mqs.mwc.mw', + 'mwcmqs_port': 443, + 'mwcmqs_use_ssl': true, + }), + ); + + // If we get here, the API call structure is correct. + return 'MWCMQS high-level API integration validated: Libmwc methods accessible and callable'; + } catch (apiError) { + // Expected to fail without real wallet, but validates API structure. + if (apiError.toString().contains('handle') || + apiError.toString().contains('wallet') || + apiError.toString().contains('connection') || + apiError.toString().contains('invalid')) { + return 'MWCMQS high-level API structure validated (expected failure without real wallet): ${apiError.toString().substring(0, 80)}...'; + } + throw apiError; + } + } catch (e) { + throw TestException( + 'MWCMQS high-level API integration test failed: ${e.toString()}', + ); + } + }); + + return allPassed; + } + + /// Get test wallet configuration. + static Future _getTestWalletConfig() async { + final walletDir = await _getTestWalletDir(); + final config = { + 'wallet_dir': walletDir, + 'check_node_api_http_addr': + 'https://mwc713.mwc.mw:443', // Working remote node. + 'chain': 'mainnet', // Use mainnet since remote node is mainnet. + 'account': 'default', + }; + + return '{"wallet_dir":"${config['wallet_dir']}","check_node_api_http_addr":"${config['check_node_api_http_addr']}","chain":"${config['chain']}","account":"${config['account']}"}'; + } + + /// Run a single test with proper error handling and result tracking. + static Future _runTest( + String name, + String description, + Future Function() testFunction, + ) async { + _logInfo('Running test: $name'); + + final stopwatch = Stopwatch()..start(); + + try { + final result = await testFunction(); + stopwatch.stop(); + + _testResults.add( + TestResult( + name: name, + description: description, + passed: true, + duration: stopwatch.elapsed, + result: result, + ), + ); + + _logInfo('Test PASSED: $name (${stopwatch.elapsedMilliseconds}ms)'); + return true; + } catch (e, stackTrace) { + stopwatch.stop(); + + _testResults.add( + TestResult( + name: name, + description: description, + passed: false, + duration: stopwatch.elapsed, + error: e.toString(), + stackTrace: stackTrace.toString(), + ), + ); + + _logError('Test FAILED: $name - $e'); + return false; + } + } + + /// Log info message. + static void _logInfo(String message) { + final timestamp = DateTime.now().toIso8601String(); + debugPrint('[$timestamp] [INFO] $message'); + } + + /// Log error message. + static void _logError(String message) { + final timestamp = DateTime.now().toIso8601String(); + debugPrint('[$timestamp] [ERROR] $message'); + } + + /// Get appropriate test wallet directory for current platform. + static Future _getTestWalletDir() async { + if (Platform.isAndroid) { + return '/data/data/com.example.flutter_libmwc_example/files/ffi_test_wallets/'; + } else if (Platform.isIOS) { + // Use proper iOS Application Support directory instead of hardcoded path + final appSupportDir = await getApplicationSupportDirectory(); + return '${appSupportDir.path}/ffi_test_wallets/'; + } else if (Platform.isLinux) { + return '/tmp/flutter_libmwc_ffi_test_wallets/'; + } else if (Platform.isWindows) { + return r'C:\temp\flutter_libmwc_ffi_test_wallets\'; + } else if (Platform.isMacOS) { + return '/tmp/flutter_libmwc_ffi_test_wallets/'; + } else { + return '/tmp/flutter_libmwc_ffi_test_wallets/'; + } + } +} + +/// Exception thrown during testing. +class TestException implements Exception { + final String message; + + const TestException(this.message); + + @override + String toString() => 'TestException: $message'; +} diff --git a/integration_test/mwc_test_result.dart b/integration_test/mwc_test_result.dart new file mode 100644 index 0000000000..27dbc2e1dd --- /dev/null +++ b/integration_test/mwc_test_result.dart @@ -0,0 +1,28 @@ +/// Represents the result of a single FFI integration test. +class TestResult { + final String name; + final String description; + final bool passed; + final Duration duration; + final String? result; + final String? error; + final String? stackTrace; + final DateTime timestamp; + + TestResult({ + required this.name, + required this.description, + required this.passed, + required this.duration, + this.result, + this.error, + this.stackTrace, + }) : timestamp = DateTime.now(); + + /// Get a summary string for this test result. + String get summary { + final status = passed ? 'PASS' : 'FAIL'; + final durationMs = duration.inMilliseconds; + return '$status: $name (${durationMs}ms)'; + } +} 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/build_app.sh b/scripts/build_app.sh index 74dc441d91..49966b8846 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -2,7 +2,9 @@ set -e -source ./env.sh +THIS_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/env.sh +source "$THIS_SCRIPT_DIR/env.sh" APP_PLATFORMS=("android" "ios" "macos" "linux" "windows") APP_NAMED_IDS=("stack_wallet" "stack_duo" "campfire") @@ -34,10 +36,10 @@ unset -v APP_NAMED_ID # optional args (with defaults) # -i: skip building the platform native deps (secure storage deps, secp256k1). SKIP_NATIVE_DEPS_BUILD=0 -# -d: use downloaded/prebuilt binaries instead of building from source where -# supported. Enables the NATIVE_PREBUILTS block in the pubspec template so -# native-assets plugins use CI prebuilts via hooks user_defines. -DOWNLOAD_CRYPTO_PLUGINS=0 +# -d: use downloaded/prebuilt native assets instead of building from source +# where supported. Enables the NATIVE_PREBUILTS block in the pubspec +# template so hooks receive the prebuilt-assets user defines. +USE_NATIVE_PREBUILTS=0 BUILD_ISAR_FROM_SOURCE=0 USE_SYSTEM_SECURE_STORAGE_DEPS=0 @@ -49,7 +51,7 @@ while getopts "v:b:p:a:idfs" opt; do p) APP_BUILD_PLATFORM="$OPTARG" ;; a) APP_NAMED_ID="$OPTARG" ;; i) SKIP_NATIVE_DEPS_BUILD=1 ;; - d) DOWNLOAD_CRYPTO_PLUGINS=1 ;; + d) USE_NATIVE_PREBUILTS=1 ;; f) BUILD_ISAR_FROM_SOURCE=1 ;; s) USE_SYSTEM_SECURE_STORAGE_DEPS=1 ;; *) usage ;; @@ -106,7 +108,7 @@ if printf '%s\0' "${APP_NAMED_IDS[@]}" | grep -Fxqz -- "${APP_NAMED_ID}"; then "${APP_PROJECT_ROOT_DIR}/scripts/app_config/shared/link_assets.sh" "${APP_NAMED_ID}" "${APP_BUILD_PLATFORM}" # shellcheck disable=SC1090 source "${APP_PROJECT_ROOT_DIR}/scripts/app_config/configure_${APP_NAMED_ID}.sh" "${APP_BUILD_PLATFORM}" - if [ "$DOWNLOAD_CRYPTO_PLUGINS" -eq 1 ]; then + if [ "$USE_NATIVE_PREBUILTS" -eq 1 ]; then # Enable CI prebuilt native assets (hooks user_defines) in pubspec.yaml so # native-assets plugins download verified binaries instead of compiling Rust. dart "${APP_PROJECT_ROOT_DIR}/tool/process_pubspec_deps.dart" "${ACTUAL_PUBSPEC}" NATIVE_PREBUILTS @@ -135,7 +137,7 @@ if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then ;; windows) # WSL cross-compile of secp256k1.dll. CI builds it natively instead. - if [ "$DOWNLOAD_CRYPTO_PLUGINS" -eq 0 ]; then + if [ "$USE_NATIVE_PREBUILTS" -eq 0 ]; then pushd "${APP_PROJECT_ROOT_DIR}/scripts/windows" ./build_secp256k1_wsl.sh popd diff --git a/scripts/download_xcode.sh b/scripts/download_xcode.sh new file mode 100755 index 0000000000..0108fd32a5 --- /dev/null +++ b/scripts/download_xcode.sh @@ -0,0 +1,215 @@ +#!/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 + +DOWNLOAD_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}" + +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}." + if [[ "$(xcode-select -p 2>/dev/null || true)" != "${TARGET_DIR}/Contents/Developer" ]]; then + echo "Run to select it: sudo xcode-select -s \"${TARGET_DIR}/Contents/Developer\"" + fi + 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 and uses sudo to install into ${TARGET_DIR}." + 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 +if [[ ! -f "$XIP_PATH" ]]; then + echo "Downloading ${FILE_NAME} (resumable; torrent alternative: ${TORRENT_URL})..." + curl -fL -C - --progress-bar -o "$XIP_PATH" "$DOWNLOAD_URL" +else + echo "Using cached archive: $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 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. +if [[ $ASSUME_YES -eq 1 ]]; then + sudo xcode-select -s "${TARGET_DIR}/Contents/Developer" + sudo xcodebuild -license accept + sudo xcodebuild -runFirstLaunch + 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 diff --git a/scripts/install_macos_build_tools.sh b/scripts/install_macos_build_tools.sh new file mode 100755 index 0000000000..565c6eba53 --- /dev/null +++ b/scripts/install_macos_build_tools.sh @@ -0,0 +1,159 @@ +#!/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), manually add to PATH +RUSTUP_BIN="/opt/homebrew/opt/rustup/bin" +if [[ -d "$RUSTUP_BIN" ]] && [[ ":$PATH:" != *":$RUSTUP_BIN:"* ]]; then + export PATH="$RUSTUP_BIN:$PATH" +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)" +bash "$SCRIPT_DIR/install_xcode.sh" + +echo "Installing Homebrew packages..." +brew install direnv rustup cmake meson ninja pkg-config gnu-sed cocoapods go protobuf cbindgen autoconf automake libtool pandoc weasyprint toilet figlet sccache + +# The Homebrew cask always installs the latest Flutter, but the app is built +# and tested against one pinned release. Install that tag from source instead. +FLUTTER_VERSION="${FLUTTER_VERSION:-3.47.4}" +FLUTTER_DIR="${FLUTTER_DIR:-$HOME/development/flutter}" + +flutter_installed_version() { + "$1" --version --machine 2>/dev/null | sed -n 's/.*"frameworkVersion": *"\([^"]*\)".*/\1/p' | head -n 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")" + +if [[ "$CURRENT_FLUTTER" == "$FLUTTER_VERSION" ]]; then + echo "Flutter $FLUTTER_VERSION already installed ($FLUTTER_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 +fi +export PATH="$FLUTTER_DIR/bin:$PATH" + +if ! command -v rustup >/dev/null 2>&1; then + die "rustup not found in PATH. Check brew install." +fi + +echo "Ensuring Rust 1.99.1 toolchain..." +rustup toolchain install 1.99.1 +rustup default 1.99.1 +rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --toolchain 1.99.1 >/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 1.99.1 || true + else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --default-toolchain 1.99.1 || 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 +rustc --version +rustup run 1.99.1 rustc --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 +grep -qF 'brew shellenv' "$ZSHRC" 2>/dev/null || \ + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> "$ZSHRC" +grep -qF "$FLUTTER_DIR/bin" "$ZSHRC" 2>/dev/null || \ + echo "export PATH=\"$FLUTTER_DIR/bin:\$PATH\"" >> "$ZSHRC" +grep -qF '$HOME/.cargo/bin' "$ZSHRC" 2>/dev/null || \ + echo 'export PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"' >> "$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_nixos_build_tools.sh b/scripts/install_nixos_build_tools.sh new file mode 100755 index 0000000000..d4b42bc80a --- /dev/null +++ b/scripts/install_nixos_build_tools.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "This installer is for Linux/NixOS only." + exit 1 +fi + +if ! command -v nix >/dev/null 2>&1; then + echo "Nix is required. Install it first: https://nixos.org/download/" + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "rustup is required. Install it first (for toolchain pinning): https://rustup.rs" + exit 1 +fi + +echo "Installing Nix profile packages..." +nix --extra-experimental-features "nix-command flakes" profile add \ + nixpkgs#direnv \ + nixpkgs#flutter \ + nixpkgs#go \ + nixpkgs#cmake \ + nixpkgs#ninja \ + nixpkgs#pkg-config \ + nixpkgs#gnumake \ + nixpkgs#gnused \ + nixpkgs#protobuf \ + nixpkgs#autoconf \ + nixpkgs#automake \ + nixpkgs#libtool \ + nixpkgs#clang || true + +echo "Ensuring Rust 1.99.1 toolchain..." +rustup toolchain install --no-self-update 1.99.1 +rustup default 1.99.1 +rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu --toolchain 1.99.1 >/dev/null 2>&1 || true + +echo "Installing optional Rust CLI build tools..." +cargo install cargo-ndk || true + +echo "Verifying toolchain..." +if command -v flutter >/dev/null 2>&1; then + flutter --version +else + echo "flutter not found in PATH." +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 +rustc --version +rustup run 1.99.1 rustc --version +go version +protoc --version || true +cmake --version | head -n 1 || true +pkg-config --version || true +autoreconf --version | head -n 1 || true +aclocal --version | head -n 1 || true + +echo "Done." diff --git a/scripts/install_xcode.sh b/scripts/install_xcode.sh new file mode 100755 index 0000000000..19d958f6a6 --- /dev/null +++ b/scripts/install_xcode.sh @@ -0,0 +1,55 @@ +#!/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..." + +XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -1 || true)" + +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 + +if ! xcode-select -p >/dev/null 2>&1 || [[ "$(xcode-select -p)" != *"/Xcode.app"* ]]; then + echo "Xcode.app found at $XCODE_APP. Setting xcode-select path..." + sudo xcode-select -s "$XCODE_APP/Contents/Developer" +else + echo "Xcode.app already configured." +fi + +sudo xcodebuild -license accept 2>/dev/null || true +sudo xcodebuild -runFirstLaunch 2>/dev/null || true 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..4069412db0 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,7 +21,7 @@ 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" .. +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" .. make -j"$(nproc)" cd "$LINUX_DIRECTORY" || exit 1 @@ -38,9 +38,25 @@ if ! [ -x "$(command -v meson)" ]; then echo 'Error: meson is not installed.' >&2 exit 1 fi -meson _build -Dmanpage=false -Dgtk_doc=false +meson _build -Dvapi=false -Dmanpage=false -Dgtk_doc=false if ! [ -x "$(command -v ninja)" ]; then echo 'Error: ninja is not installed.' >&2 exit 1 fi ninja -C _build + +# 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" < Date: Sun, 1 Nov 2026 20:57:56 +0200 Subject: [PATCH 02/21] docs: note that a changed PATH or NIX_* variable reruns all native-asset hooks Measured on macOS with the unmodified upstream tree: a repeated build takes 33-40 s with an unchanged environment, but 536 s after one extra directory in PATH and 156-164 s after adding a NIX_* variable or restoring PATH, because hooks_runner reruns every hook when the environment it hashes changes. --- docs/building.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/building.md b/docs/building.md index d6efdf786a..93e53e259b 100644 --- a/docs/building.md +++ b/docs/building.md @@ -455,6 +455,10 @@ the version that dnix pins to rebuild Apple libraries byte for byte. License acc 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. + ### Optional: sccache for native-asset builds `make sccache-setup` writes `~/.cargo/config.toml` with `rustc-wrapper = "sccache"` (it never overwrites an From 06024096f212af1818c726d9ce251faf480e079c Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:12:11 +0200 Subject: [PATCH 03/21] xcode: only use sudo when the install target is not writable download_xcode.sh used sudo for rm/mv/xattr unconditionally. It now checks whether the parent of the target directory is writable and only then uses sudo, so Xcode can be installed into ~/Applications without a password. Verified on macOS 27.2: -d ~/Applications/Xcode_27.1_test.app installed Xcode 27.1 (27A9275), the extracted app has no quarantine attribute, and free space dropped by about 4 GB; hash and signature checks passed before it. The install-time prompt text now says about 20 GB free are needed. --- scripts/download_xcode.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/download_xcode.sh b/scripts/download_xcode.sh index 0108fd32a5..80ba4d10db 100755 --- a/scripts/download_xcode.sh +++ b/scripts/download_xcode.sh @@ -95,6 +95,13 @@ if [[ "$TARGET_DIR" != *.app || "$TARGET_DIR" == "/" ]]; then exit 1 fi +# 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 + DOWNLOAD_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}" @@ -130,7 +137,7 @@ if [[ $DRY_RUN -eq 1 ]]; then fi if [[ $ASSUME_YES -eq 0 ]]; then - echo "This downloads ~2 GB, needs ~20 GB free and uses sudo to install into ${TARGET_DIR}." + 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; } @@ -194,10 +201,10 @@ if [[ ! -d "${EXTRACT_DIR}/Xcode.app" ]]; then fi # 5. Install -echo "Installing to ${TARGET_DIR} (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 +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. From 3ac372d6f9e0ef5667ea77fa45bbf14522e4a155 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:29:59 +0200 Subject: [PATCH 04/21] bootstrap: do not upgrade installed formulae, make the report tools optional install_macos_build_tools.sh aborted at the first brew failure. On a machine where weasyprint was installed another way, brew could not link it, set -e stopped the script, and the Flutter step was never reached. It also upgraded formulae that were already installed (cmake, go), changing the build machine under the user. - export HOMEBREW_NO_INSTALL_UPGRADE=1 for the required packages - install pandoc/weasyprint/toilet/figlet (only used by the bm/ report rendering) in a separate, non-fatal step Verified on macOS 27.2: first run failed as described; after the change the installer ended with rc=0, cloned Flutter 3.47.4 into a temporary FLUTTER_DIR, left the existing Flutter 3.47.2 untouched, and only appended the brew shellenv and Flutter PATH lines to ~/.zshrc (restored afterwards). --- scripts/install_macos_build_tools.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/install_macos_build_tools.sh b/scripts/install_macos_build_tools.sh index 565c6eba53..ad1679be55 100755 --- a/scripts/install_macos_build_tools.sh +++ b/scripts/install_macos_build_tools.sh @@ -51,7 +51,13 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)" bash "$SCRIPT_DIR/install_xcode.sh" echo "Installing Homebrew packages..." -brew install direnv rustup cmake meson ninja pkg-config gnu-sed cocoapods go protobuf cbindgen autoconf automake libtool pandoc weasyprint toilet figlet sccache +# 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 + +# 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 the app is built # and tested against one pinned release. Install that tag from source instead. From 389259d71eb978d4093c207f630dbea34f86c30b Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:21:10 +0200 Subject: [PATCH 05/21] build: call build_app.sh from scripts/ like CI, drop redundant keys block make build-macos now runs ./build_app.sh from the scripts directory (as the CI jobs and the other targets do), so scripts/build_app.sh stays exactly as in staging; the earlier path-resolution and variable-rename edits are reverted. make build-linux no longer recreates lib/external_api_keys.dart: prebuild.sh already creates it, and the block never ran in any of the Linux builds I did. Verified on macOS 27.2: make build-macos OK, Stack Wallet.app with the unchanged bundle ID. The Linux target was only checked with make -n after this change. --- Makefile | 6 +----- scripts/build_app.sh | 18 ++++++++---------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 04dafa78fa..fa7ebdc09b 100644 --- a/Makefile +++ b/Makefile @@ -173,7 +173,7 @@ build-macos: check-reqs-macos check-sdk-macos ## Build macOS release (same steps @# so a previous PREBUILTS setting would otherwise stick. @rm -f pubspec.yaml @echo "--- Configuring project (scripts/build_app.sh)..." - @echo yes | env $(MACOS_ENV_UNSET) scripts/build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p macos -a stack_wallet -s $(if $(filter 1,$(PREBUILTS)),-d) + @cd scripts && echo yes | env $(MACOS_ENV_UNSET) ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p macos -a stack_wallet -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 @@ -230,10 +230,6 @@ build-linux: check-reqs ## Build Linux Release through Flutter native-assets hoo echo "[INFO] .cargo/config.toml exists; make sure it sets [env] PROTOC if protoc fails to run (NixOS)."; \ fi @echo "--- Building app..." - @if [ ! -f lib/external_api_keys.dart ]; then \ - echo "[WARN] lib/external_api_keys.dart missing; recreating template."; \ - printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\n' > lib/external_api_keys.dart; \ - fi @$(FLUTTER) pub get @mkdir -p scripts/linux/pc @printf '%s\n' \ diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 49966b8846..74dc441d91 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -2,9 +2,7 @@ set -e -THIS_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/env.sh -source "$THIS_SCRIPT_DIR/env.sh" +source ./env.sh APP_PLATFORMS=("android" "ios" "macos" "linux" "windows") APP_NAMED_IDS=("stack_wallet" "stack_duo" "campfire") @@ -36,10 +34,10 @@ unset -v APP_NAMED_ID # optional args (with defaults) # -i: skip building the platform native deps (secure storage deps, secp256k1). SKIP_NATIVE_DEPS_BUILD=0 -# -d: use downloaded/prebuilt native assets instead of building from source -# where supported. Enables the NATIVE_PREBUILTS block in the pubspec -# template so hooks receive the prebuilt-assets user defines. -USE_NATIVE_PREBUILTS=0 +# -d: use downloaded/prebuilt binaries instead of building from source where +# supported. Enables the NATIVE_PREBUILTS block in the pubspec template so +# native-assets plugins use CI prebuilts via hooks user_defines. +DOWNLOAD_CRYPTO_PLUGINS=0 BUILD_ISAR_FROM_SOURCE=0 USE_SYSTEM_SECURE_STORAGE_DEPS=0 @@ -51,7 +49,7 @@ while getopts "v:b:p:a:idfs" opt; do p) APP_BUILD_PLATFORM="$OPTARG" ;; a) APP_NAMED_ID="$OPTARG" ;; i) SKIP_NATIVE_DEPS_BUILD=1 ;; - d) USE_NATIVE_PREBUILTS=1 ;; + d) DOWNLOAD_CRYPTO_PLUGINS=1 ;; f) BUILD_ISAR_FROM_SOURCE=1 ;; s) USE_SYSTEM_SECURE_STORAGE_DEPS=1 ;; *) usage ;; @@ -108,7 +106,7 @@ if printf '%s\0' "${APP_NAMED_IDS[@]}" | grep -Fxqz -- "${APP_NAMED_ID}"; then "${APP_PROJECT_ROOT_DIR}/scripts/app_config/shared/link_assets.sh" "${APP_NAMED_ID}" "${APP_BUILD_PLATFORM}" # shellcheck disable=SC1090 source "${APP_PROJECT_ROOT_DIR}/scripts/app_config/configure_${APP_NAMED_ID}.sh" "${APP_BUILD_PLATFORM}" - if [ "$USE_NATIVE_PREBUILTS" -eq 1 ]; then + if [ "$DOWNLOAD_CRYPTO_PLUGINS" -eq 1 ]; then # Enable CI prebuilt native assets (hooks user_defines) in pubspec.yaml so # native-assets plugins download verified binaries instead of compiling Rust. dart "${APP_PROJECT_ROOT_DIR}/tool/process_pubspec_deps.dart" "${ACTUAL_PUBSPEC}" NATIVE_PREBUILTS @@ -137,7 +135,7 @@ if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then ;; windows) # WSL cross-compile of secp256k1.dll. CI builds it natively instead. - if [ "$USE_NATIVE_PREBUILTS" -eq 0 ]; then + if [ "$DOWNLOAD_CRYPTO_PLUGINS" -eq 0 ]; then pushd "${APP_PROJECT_ROOT_DIR}/scripts/windows" ./build_secp256k1_wsl.sh popd From 708b7877cd014d50ce758f05065d862cb774b256 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:19:09 +0200 Subject: [PATCH 06/21] xcode: retry interrupted downloads archive.org sometimes resets the connection (curl exit 56) a minute into the transfer, which aborted the script even though the download can be resumed. curl now retries up to 8 times (--retry-all-errors) and resumes with -C -. Seen in a fresh macOS 27.2 VM; the retry itself is exercised in the next VM run. --- scripts/download_xcode.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/download_xcode.sh b/scripts/download_xcode.sh index 80ba4d10db..aa3e9d0f98 100755 --- a/scripts/download_xcode.sh +++ b/scripts/download_xcode.sh @@ -153,7 +153,7 @@ fi # 1. Resumable download if [[ ! -f "$XIP_PATH" ]]; then echo "Downloading ${FILE_NAME} (resumable; torrent alternative: ${TORRENT_URL})..." - curl -fL -C - --progress-bar -o "$XIP_PATH" "$DOWNLOAD_URL" + curl -fL -C - --retry 8 --retry-delay 5 --retry-all-errors --progress-bar -o "$XIP_PATH" "$DOWNLOAD_URL" else echo "Using cached archive: $XIP_PATH" fi From 8f7eb3b1cb5c439b5286e5914bb6932c94ab35bd Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:27:39 +0200 Subject: [PATCH 07/21] build: align make build-linux with the CI flags, Xcode mirror override, docs make build-linux now passes -d (PREBUILTS=1, the default, like make build-macos and the CI job) and -s with SYSTEM_DEPS=1. With -s the Makefile sets only USE_SYSTEM_SECURE_STORAGE_DEPS=1 for the app build; the local libsecret pkg-config override is kept for the default path (it is needed where libsecret is built from source, for example on NixOS). download_xcode.sh accepts XCODE_XIP_URL to download from another mirror; the SHA-1 and the Apple signature are still checked. docs: a second Xcode via DEVELOPER_DIR, and what reaches the hooks for sccache (exported RUSTC_WRAPPER does not, the cargo config file does). Verified: NixOS (nix develop) make build-linux with -d builds the bundle; Ubuntu 24.04 container make build-linux SYSTEM_DEPS=1 (-d -s, the CI flags) builds the bundle; make -n and bash -n pass for all targets and scripts. --- Makefile | 22 ++++++++++++---------- docs/building.md | 10 ++++++---- scripts/download_xcode.sh | 3 ++- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index fa7ebdc09b..50d686bfdd 100644 --- a/Makefile +++ b/Makefile @@ -167,6 +167,8 @@ clean: ## Remove artifacts and fix permissions # --- 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 build-macos: check-reqs-macos check-sdk-macos ## Build macOS release (same steps as upstream CI; PREBUILTS=0 compiles native assets from source) @# pubspec.yaml is generated; build_app.sh only regenerates it from the template when unchanged, @@ -217,7 +219,7 @@ build-linux: check-reqs ## Build Linux Release through Flutter native-assets hoo @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) + @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 @@ -250,16 +252,16 @@ build-linux: check-reqs ## Build Linux Release through Flutter native-assets hoo else \ echo "[WARN] podman/docker not found; skipping coinlib:build_linux"; \ fi - @SYSPROF_PC_DIR=$$(dirname "$$(find /nix/store -path '*/lib/pkgconfig/sysprof-capture-4.pc' 2>/dev/null | head -n1)"); \ + @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_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; } - @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= \ + { 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 + PROTOC="$(PROTOC_PATH)" $(FLUTTER) build linux --release; \ + fi diff --git a/docs/building.md b/docs/building.md index 93e53e259b..66aee21cde 100644 --- a/docs/building.md +++ b/docs/building.md @@ -447,8 +447,8 @@ Two ways to get Flutter 3.47.x, Rust 1.99.1, Go, CMake and CocoaPods. Both need ### Xcode (optional download) -`make bootstrap-xcode` only checks and selects an installed Xcode. If you have none, you can opt in to -`make bootstrap-xcode-download` (`scripts/download_xcode.sh`), which downloads a `.xip` from +`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 @@ -462,8 +462,10 @@ Flutter reruns every native-asset build hook when `PATH` or any `NIX_*` variable ### Optional: sccache for native-asset builds `make sccache-setup` writes `~/.cargo/config.toml` with `rustc-wrapper = "sccache"` (it never overwrites an -existing file). With Flutter 3.47.x the build hooks only see a filtered environment, so exporting -`RUSTC_WRAPPER` does nothing, but cargo still reads this file through `HOME`. Check `sccache --show-stats` +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 diff --git a/scripts/download_xcode.sh b/scripts/download_xcode.sh index aa3e9d0f98..6ddc23e2fb 100755 --- a/scripts/download_xcode.sh +++ b/scripts/download_xcode.sh @@ -102,7 +102,8 @@ if [[ ! -w "$(dirname "$TARGET_DIR")" ]]; then SUDO="sudo" fi -DOWNLOAD_URL="https://archive.org/download/${IA_ITEM}/${FILE_NAME}" +# 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}" From 548defb532589dfe9378a642bc81f67a52ae3cf1 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:06:17 +0200 Subject: [PATCH 08/21] build: add clean-app and clean-native targets, record sccache hits in bm/bench.sh clean-app removes build/ and the Pods but keeps .dart_tool/hooks_runner, so the next build does not rebuild the native assets; clean-native removes only the native-asset outputs. bm/bench.sh zeroes the sccache statistics before the build and stores 'sccache hits/executed requests' in the comment column of the CSV. --- Makefile | 13 ++++++++++++- bm/bench.sh | 9 ++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 50d686bfdd..824cdffcc2 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 export APP_PROJECT_ROOT_DIR export PUB_CACHE -.PHONY: help check-reqs check-reqs-macos check-sdk-macos bootstrap-macos bootstrap-xcode bootstrap-xcode-download sccache-setup cache-stats clean build-macos build-linux diagnose-env-macos test-mwc +.PHONY: help check-reqs check-reqs-macos check-sdk-macos bootstrap-macos bootstrap-xcode bootstrap-xcode-download sccache-setup cache-stats clean clean-app clean-native build-macos build-linux diagnose-env-macos test-mwc help: ## Show available commands @echo "Available targets:" @@ -170,6 +170,17 @@ PREBUILTS ?= 1 # Linux only: SYSTEM_DEPS=1 uses the system libsecret/jsoncpp packages instead of building them (like the CI image). SYSTEM_DEPS ?= 0 +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) @# pubspec.yaml is generated; build_app.sh only regenerates it from the template when unchanged, @# so a previous PREBUILTS setting would otherwise stick. diff --git a/bm/bench.sh b/bm/bench.sh index 6863c55ddc..533ad0ef50 100755 --- a/bm/bench.sh +++ b/bm/bench.sh @@ -90,6 +90,7 @@ fi # Build echo "Building: make ${BUILD_TARGET} ${MAKE_FLAGS} " | banner +sccache --zero-stats >/dev/null 2>&1 || true START=$(date +%s) make ${BUILD_TARGET} ${MAKE_FLAGS} 2>&1 | tail -5 RC=$? @@ -97,6 +98,12 @@ 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}') + SCC_HIT=$(sccache --show-stats 2>/dev/null | awk '/^Cache hits / {print $NF}') + [ -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) DISK_DELTA=$((DISK_AFTER - DISK_BEFORE)) @@ -105,7 +112,7 @@ 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}," >> "$OUTFILE" +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 From c5a83409b9c7dc7c23a9a7203d0510dbbf89f567 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:27:53 +0200 Subject: [PATCH 09/21] build: add stack-bom.toml and make verify-bom stack-bom.toml records the expected toolchain (Flutter, Dart, Rust, Go, Xcode) and the git revisions of the pinned native plugins. make verify-bom (dart tool/verify_bom.dart, no packages needed) checks the local machine and pubspec.lock against it: a mismatch in Rust, Dart, minimum Flutter/Go or a plugin pin fails, a different recommended Flutter or Xcode only warns. dart tool/verify_bom.dart --update regenerates the [plugins] section from pubspec.lock. Verified on macOS 27.2: passes with 1 warning (Flutter 3.47.2 vs. recommended 3.47.4); a wrong Rust version and a wrong plugin pin both exit 1. --- Makefile | 5 +- docs/building.md | 4 + stack-bom.toml | 28 ++++++ tool/verify_bom.dart | 222 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 stack-bom.toml create mode 100644 tool/verify_bom.dart diff --git a/Makefile b/Makefile index 824cdffcc2..d32a56a522 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 export APP_PROJECT_ROOT_DIR export PUB_CACHE -.PHONY: help check-reqs check-reqs-macos check-sdk-macos bootstrap-macos bootstrap-xcode bootstrap-xcode-download sccache-setup cache-stats clean clean-app clean-native build-macos build-linux diagnose-env-macos test-mwc +.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 diagnose-env-macos test-mwc help: ## Show available commands @echo "Available targets:" @@ -148,6 +148,9 @@ sccache-setup: ## Opt-in: use sccache for the Rust/C builds run by native-asset @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 + @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 diff --git a/docs/building.md b/docs/building.md index 66aee21cde..b12bb10f14 100644 --- a/docs/building.md +++ b/docs/building.md @@ -459,6 +459,10 @@ if you can, since third-party redistribution of Xcode may conflict with Apple's 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 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..3ca18bc63e --- /dev/null +++ b/tool/verify_bom.dart @@ -0,0 +1,222 @@ +// 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 { + final r = await Process.run(exe, args); + if (r.exitCode != 0) return null; + return '${r.stdout}${r.stderr}'.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 fl = await _run('flutter', ['--version', '--machine']); + if (fl == null) { + _fail('flutter not found in PATH'); + } else { + final info = + jsonDecode(fl.substring(fl.indexOf('{'))) 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'); + } + } + + // 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) { + if (!refs.containsKey(n)) continue; + if (pinned[n] == refs[n]) { + _ok('$n ${refs[n]!.substring(0, 7)}'); + } else { + _fail( + '$n is ${refs[n]!.substring(0, 7)} in pubspec.lock but ${pinned[n]?.substring(0, 7)} in $_bomFile (run --update)', + ); + } + } + } + + stdout.writeln('\n$_failures failure(s), $_warnings warning(s).'); + exit(_failures == 0 ? 0 : 1); +} From baa3b85f1986316b2bed76b924c1753a0f788f59 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:30:22 +0200 Subject: [PATCH 10/21] build: add make build-windows and a runner workflow to test the make targets make build-windows wraps the steps of the build-windows CI job (prebuild.sh, build_app.sh -p windows -d -s, pub get, coinlib:build_windows, flutter build windows). WINDOWS_COPY_ASSETS=1 replaces the asset links with copies for runners that cannot create symlinks. .github/workflows/make-build.yaml runs make build-windows and make build-macos on hosted runners (manual dispatch or pushes to ci/make-* branches). --- .github/workflows/make-build.yaml | 50 ++++++++++++++++++++++++++ Makefile | 16 ++++++++- scripts/windows/replace_asset_links.sh | 15 ++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/make-build.yaml create mode 100755 scripts/windows/replace_asset_links.sh diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml new file mode 100644 index 0000000000..d8fba846ad --- /dev/null +++ b/.github/workflows/make-build.yaml @@ -0,0 +1,50 @@ +name: Make build + +# Builds with the Makefile targets on the hosted runners. Manual or on pushes to ci/make-* branches. +on: + workflow_dispatch: + push: + branches: ['ci/make-*'] + +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 }} diff --git a/Makefile b/Makefile index d32a56a522..58afb22b84 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 export APP_PROJECT_ROOT_DIR export PUB_CACHE -.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 diagnose-env-macos test-mwc +.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 test-mwc help: ## Show available commands @echo "Available targets:" @@ -279,3 +279,17 @@ build-linux: check-reqs ## Build Linux Release through Flutter native-assets hoo PKG_CONFIG_LIBDIR="$(CURDIR)/scripts/linux/pc:$$SYSPROF_PC_DIR:$$PC_PATH" \ PROTOC="$(PROTOC_PATH)" $(FLUTTER) build linux --release; \ fi + +# Windows: run from Git Bash. Same steps as the "build-windows" job in .github/workflows/build.yaml. +# WINDOWS_COPY_ASSETS=1 replaces the asset links with copies (needed on runners that cannot create symlinks). +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 ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p windows -a $(APP_NAME) -d -s + @if [ "$(WINDOWS_COPY_ASSETS)" = "1" ]; then bash scripts/windows/replace_asset_links.sh; fi + @$(FLUTTER) pub get + @$(DART) run coinlib:build_windows + @$(FLUTTER) build windows --release diff --git a/scripts/windows/replace_asset_links.sh b/scripts/windows/replace_asset_links.sh new file mode 100755 index 0000000000..c6ecb65fc6 --- /dev/null +++ b/scripts/windows/replace_asset_links.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Replace the asset directory links created by link_assets.sh with real copies. +# Needed where `mklink /D` is not allowed (e.g. the GitHub Actions windows-2022 runner user lacks +# SeCreateSymbolicLinkPrivilege), because Flutter's asset resolver cannot follow the broken reparse points. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +for dirname in default_themes icon lottie in_app_logo_icons svg; do + target="assets/${dirname}" + source="asset_sources/${dirname}/stack_wallet" + if [ -e "$target" ] || [ -L "$target" ]; then + cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + fi + mkdir -p "$target" + cp -r "${source}/." "$target/" +done From 576d1fce7495d45cc38ff4a7dbe3849a3173911f Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:03:30 +0200 Subject: [PATCH 11/21] ci: make-build workflow is manual only and gets a Linux job --- .github/workflows/make-build.yaml | 32 ++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml index d8fba846ad..d726a2f628 100644 --- a/.github/workflows/make-build.yaml +++ b/.github/workflows/make-build.yaml @@ -1,10 +1,8 @@ name: Make build -# Builds with the Makefile targets on the hosted runners. Manual or on pushes to ci/make-* branches. +# Builds with the Makefile targets on the hosted runners. Manual only. on: workflow_dispatch: - push: - branches: ['ci/make-*'] jobs: windows: @@ -48,3 +46,31 @@ jobs: 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: 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: Build + run: make build-linux VERSION=0.0.0 BUILD_NUM=${{ github.run_number }} From aa6841d5779b10def31d12e53d9dd1892418ba93 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:06:17 +0200 Subject: [PATCH 12/21] docs: describe the native Windows build and make build-windows --- docs/building.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/building.md b/docs/building.md index b12bb10f14..3d7f9441fd 100644 --- a/docs/building.md +++ b/docs/building.md @@ -180,7 +180,11 @@ 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` so the asset directories are copied instead of linked. The WSL2 route described at the top of this guide still works. + +### Linux Run the following commands or launch via Android Studio: ``` flutter pub get From ae678f295451b1da7eef654ff160ab7a2cb83bc6 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:06:50 +0200 Subject: [PATCH 13/21] build: add make build-android wrapping the CI steps (unsigned) --- Makefile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 58afb22b84..1d00b0b46d 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 export APP_PROJECT_ROOT_DIR export PUB_CACHE -.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 test-mwc +.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 test-mwc build-android help: ## Show available commands @echo "Available targets:" @@ -293,3 +293,15 @@ build-windows: ## Build Windows release (Git Bash; same steps as CI) @$(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). The release APKs are unsigned unless android/key.properties exists. +build-android: ## Build Android APKs (same steps as CI, unsigned without android/key.properties) + @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 + From f5c0fdca43f6ba766145899bc80d315c4b8a11a9 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:58:36 +0200 Subject: [PATCH 14/21] build: make build-android creates a throw-away debug key when no signing key exists The release build fails at packageRelease without android/key.properties (SigningConfig release is missing storeFile). android-dev-key creates a debug keystore and key.properties for local builds only; both files are git-ignored and the APKs are not for distribution. --- .gitignore | 2 ++ Makefile | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index b854b54aeb..7151970281 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,5 @@ bm/results/ # Nix dev shell: generated wrappers .nix-bin/ .direnv/ +/android/key.properties +/android/dev-keystore.jks diff --git a/Makefile b/Makefile index 1d00b0b46d..77d5698a30 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 export APP_PROJECT_ROOT_DIR export PUB_CACHE -.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 test-mwc build-android +.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 test-mwc build-android android-dev-key help: ## Show available commands @echo "Available targets:" @@ -296,8 +296,17 @@ build-windows: ## Build Windows release (Git Bash; same steps as CI) # 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). The release APKs are unsigned unless android/key.properties exists. -build-android: ## Build Android APKs (same steps as CI, unsigned without android/key.properties) +# (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 From 488d6281b6933d067375480a80004cb80e5e5cad Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:59:05 +0200 Subject: [PATCH 15/21] build: add make build-ios wrapping the CI steps (no code signing) --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 77d5698a30..7ea0188280 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 export APP_PROJECT_ROOT_DIR export PUB_CACHE -.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 test-mwc build-android android-dev-key +.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 test-mwc build-android android-dev-key build-ios help: ## Show available commands @echo "Available targets:" @@ -314,3 +314,11 @@ build-android: android-dev-key ## Build Android APKs (same steps as CI; signed w @$(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 + From 46202dabe5b4b507b4d9560166db49c00699333d Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:15:24 +0200 Subject: [PATCH 16/21] docs: describe make build-android and make build-ios --- docs/building.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/building.md b/docs/building.md index 3d7f9441fd..24d8d9cb4a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -184,6 +184,10 @@ Note on Emulators: Only x86_64 emulators are supported, x86 emulators will not w 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` so the asset directories are copied instead of linked. 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: ``` From 84a66156a5fe074497a694e52dc961f2d328a2b7 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:28:03 +0200 Subject: [PATCH 17/21] ci: make-build workflow gets an iOS job and runs make verify-bom on Linux --- .github/workflows/make-build.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml index d726a2f628..544028b54c 100644 --- a/.github/workflows/make-build.yaml +++ b/.github/workflows/make-build.yaml @@ -72,5 +72,28 @@ jobs: 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: + 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 }} From 60ac9eec3f3829bd2179a4d456b772b1e8684a14 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:42:34 +0200 Subject: [PATCH 18/21] ci: free disk space in the Linux make-build job --- .github/workflows/make-build.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml index 544028b54c..08d0907606 100644 --- a/.github/workflows/make-build.yaml +++ b/.github/workflows/make-build.yaml @@ -60,6 +60,10 @@ jobs: - 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 From a694997905c6a97ec15f4b0b5a0959c629e1e7a2 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:29:28 +0200 Subject: [PATCH 19/21] build: fix review findings in bootstrap, Windows asset copies, Linux deps and bm - build-windows: with WINDOWS_COPY_ASSETS=1 remove the asset copies of an earlier run before build_app.sh runs link_assets.sh, whose `rm -f` cannot delete real directories (second run failed) - bootstrap-macos: skip the Homebrew installer in a Nix shell with one conditional recipe instead of `exit 0`, which only ended that line's shell; failures of the Xcode step now propagate - build_secure_storage_deps.sh: stop when cmake, make, meson or ninja fails, before writing the pkg-config file; reconfigure an existing meson build directory instead of failing on it - install_macos_build_tools.sh: put rustup on PATH after `brew install` (resolved with `brew --prefix rustup`), not only if its directory existed before - install_xcode.sh: keep any valid selected Xcode (Xcode_27.1.app, ~/Applications, ...) and only pick one from /Applications when the selection is missing or the Command Line Tools - mwc_ffi_test_service: the invalid-slatepack assertion no longer sits inside its own catch - bm/compare.py: pair measurements by label and host instead of the first row of each file - bm/bench.sh: record failed builds and return make's exit status --- Makefile | 10 +++++---- bm/bench.sh | 8 ++++++- bm/compare.py | 14 ++++++------ integration_test/mwc_ffi_test_service.dart | 25 ++++++++++++++-------- scripts/install_macos_build_tools.sh | 15 ++++++++----- scripts/install_xcode.sh | 22 +++++++++++-------- scripts/linux/build_secure_storage_deps.sh | 13 +++++++---- scripts/windows/replace_asset_links.sh | 5 +++++ 8 files changed, 74 insertions(+), 38 deletions(-) diff --git a/Makefile b/Makefile index 7ea0188280..07456a0a8f 100644 --- a/Makefile +++ b/Makefile @@ -106,11 +106,11 @@ 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 0; \ + $(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 - @bash scripts/install_macos_build_tools.sh - @rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --toolchain $(RUST_VERSION) >/dev/null 2>&1 || true else @echo "[ERROR] bootstrap-macos is macOS-only." @exit 1 @@ -288,6 +288,8 @@ 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 + @# link_assets.sh (run by build_app.sh) cannot remove the real directories a previous copy run left behind. + @if [ "$(WINDOWS_COPY_ASSETS)" = "1" ]; then bash scripts/windows/replace_asset_links.sh --clean; fi @cd scripts && echo yes | MWEBD_FETCH=1 ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p windows -a $(APP_NAME) -d -s @if [ "$(WINDOWS_COPY_ASSETS)" = "1" ]; then bash scripts/windows/replace_asset_links.sh; fi @$(FLUTTER) pub get diff --git a/bm/bench.sh b/bm/bench.sh index 533ad0ef50..8ceb9ad179 100755 --- a/bm/bench.sh +++ b/bm/bench.sh @@ -92,8 +92,11 @@ fi 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=$? +RC=${PIPESTATUS[0]} +set -e END=$(date +%s) WALL=$((END - START)) @@ -136,3 +139,6 @@ tail -n +2 "$OUTFILE" | while IFS=, read -r label branch commit host arch os_nam [ "$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/compare.py b/bm/compare.py index f600e2c75c..b41fb56c89 100755 --- a/bm/compare.py +++ b/bm/compare.py @@ -138,15 +138,17 @@ def compare(dir1, dir2=None): print(f"\n{'═'*80}") print(f" Delta: {os.path.basename(dir1)} → {os.path.basename(dir2)}") print(f"{'═'*80}") - labels1 = {r["label"] for r in rows1} - labels2 = {r["label"] for r in rows2} - for label in sorted(labels1 & labels2): - t1 = wall_s(rows1[0]["wall_sec"]) - t2 = wall_s(rows2[0]["wall_sec"]) + # 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} {wall_fmt(t1)} → {wall_fmt(t2)} ({sign}{pct:.0f}%)") + print(f" {hr_label(label):25s} {host:12s} {wall_fmt(t1)} → {wall_fmt(t2)} ({sign}{pct:.0f}%)") if __name__ == "__main__": diff --git a/integration_test/mwc_ffi_test_service.dart b/integration_test/mwc_ffi_test_service.dart index 8d73a5c585..5fd915d345 100644 --- a/integration_test/mwc_ffi_test_service.dart +++ b/integration_test/mwc_ffi_test_service.dart @@ -628,21 +628,28 @@ class FFITestService { () async { try { final bogus = 'NOT_A_SLATEPACK'; + String? slateJson; + Object? decodeError; try { final dec = await Libmwc.decodeSlatepack(slatepack: bogus); - // High-level API may not throw; validate empty slate JSON returned. - if (dec.slateJson.isNotEmpty) { - throw TestException( - 'Invalid slatepack returned non-empty slate JSON', - ); - } - return 'Invalid slatepack handled gracefully (empty slate JSON)'; + slateJson = dec.slateJson; } catch (e) { - // If it throws, that's also acceptable as graceful failure. - final es = e.toString(); + decodeError = e; + } + // If the decoder throws, that's also acceptable as graceful failure. + if (decodeError != null) { + final es = decodeError.toString(); final trunc = es.substring(0, es.length < 80 ? es.length : 80); return 'Invalid slatepack decode threw as expected: $trunc...'; } + // High-level API may not throw; validate empty slate JSON returned. + // Outside the catch above, so a failed assertion fails the test. + if (slateJson!.isNotEmpty) { + throw TestException( + 'Invalid slatepack returned non-empty slate JSON', + ); + } + return 'Invalid slatepack handled gracefully (empty slate JSON)'; } catch (e) { throw TestException('Invalid format handling failed: $e'); } diff --git a/scripts/install_macos_build_tools.sh b/scripts/install_macos_build_tools.sh index ad1679be55..e317a0e65b 100755 --- a/scripts/install_macos_build_tools.sh +++ b/scripts/install_macos_build_tools.sh @@ -41,11 +41,15 @@ if ! command -v brew >/dev/null 2>&1; then fi fi -# rustup is keg-only in Homebrew (conflicts with rust), manually add to PATH -RUSTUP_BIN="/opt/homebrew/opt/rustup/bin" -if [[ -d "$RUSTUP_BIN" ]] && [[ ":$PATH:" != *":$RUSTUP_BIN:"* ]]; then - export PATH="$RUSTUP_BIN:$PATH" -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" @@ -54,6 +58,7 @@ 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. diff --git a/scripts/install_xcode.sh b/scripts/install_xcode.sh index 19d958f6a6..18b1b54bfe 100755 --- a/scripts/install_xcode.sh +++ b/scripts/install_xcode.sh @@ -36,19 +36,23 @@ fi echo "Checking Xcode..." -XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -1 || true)" +# 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 [[ -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: +if [[ "$SELECTED_DEV" == /*.app/Contents/Developer && -x "$SELECTED_DEV/usr/bin/xcodebuild" ]]; then + echo "Xcode already configured: ${SELECTED_DEV%/Contents/Developer}" +else + XCODE_APP="$(ls -d "$XCODE_APPS_DIR"/Xcode*.app 2>/dev/null | head -1 || true)" + 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 - -if ! xcode-select -p >/dev/null 2>&1 || [[ "$(xcode-select -p)" != *"/Xcode.app"* ]]; then - echo "Xcode.app found at $XCODE_APP. Setting xcode-select path..." + fi + echo "Xcode found at $XCODE_APP. Setting xcode-select path..." sudo xcode-select -s "$XCODE_APP/Contents/Developer" -else - echo "Xcode.app already configured." fi sudo xcodebuild -license accept 2>/dev/null || true diff --git a/scripts/linux/build_secure_storage_deps.sh b/scripts/linux/build_secure_storage_deps.sh index 4069412db0..c58cd80d0e 100755 --- a/scripts/linux/build_secure_storage_deps.sh +++ b/scripts/linux/build_secure_storage_deps.sh @@ -21,8 +21,8 @@ cd jsoncpp || exit 1 git checkout $JSONCPP_TAG mkdir -p build cd build || exit 1 -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" .. -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,12 +38,17 @@ if ! [ -x "$(command -v meson)" ]; then echo 'Error: meson is not installed.' >&2 exit 1 fi -meson _build -Dvapi=false -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. diff --git a/scripts/windows/replace_asset_links.sh b/scripts/windows/replace_asset_links.sh index c6ecb65fc6..6b4cb433d4 100755 --- a/scripts/windows/replace_asset_links.sh +++ b/scripts/windows/replace_asset_links.sh @@ -1,15 +1,20 @@ #!/usr/bin/env bash # Replace the asset directory links created by link_assets.sh with real copies. +# With --clean it only removes them (links or copies) and copies nothing: link_assets.sh runs `rm -f` +# on each target, which cannot remove the real directories a previous run left behind. # Needed where `mklink /D` is not allowed (e.g. the GitHub Actions windows-2022 runner user lacks # SeCreateSymbolicLinkPrivilege), because Flutter's asset resolver cannot follow the broken reparse points. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/../.." +clean_only=0 +[ "${1:-}" = "--clean" ] && clean_only=1 for dirname in default_themes icon lottie in_app_logo_icons svg; do target="assets/${dirname}" source="asset_sources/${dirname}/stack_wallet" if [ -e "$target" ] || [ -L "$target" ]; then cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" fi + [ "$clean_only" = "1" ] && continue mkdir -p "$target" cp -r "${source}/." "$target/" done From 9aef56ab05a5e2da0f0ae6f69b890befe5d88941 Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:49:26 +0200 Subject: [PATCH 20/21] build: fix make targets and tooling issues - Remove the MWC FFI integration tests; they belong in flutter_libmwc. - Run prepare_release_bundle.py from make build-linux. - Honor APP_NAME on macOS and make WINDOWS_COPY_ASSETS copy in link_assets.sh. - Stop rewriting the tracked libsecret pkg-config file and drop duplicate toolchain steps. - Harden verify_bom.dart for Windows and missing plugin pins. - Fix bootstrap/Xcode selection, download resume, and benchmark script edge cases. --- .gitignore | 1 + Makefile | 72 +- README.md | 4 +- bm/bench.sh | 16 +- bm/compare.py | 3 +- bm/render.sh | 6 +- bm/run_all.sh | 3 + docs/building.md | 17 +- flake.nix | 28 +- integration_test/mwc_ffi_test.dart | 20 - integration_test/mwc_ffi_test_service.dart | 1028 -------------------- integration_test/mwc_test_result.dart | 28 - scripts/app_config/shared/link_assets.sh | 28 +- scripts/download_xcode.sh | 86 +- scripts/install_macos_build_tools.sh | 74 +- scripts/install_nixos_build_tools.sh | 67 -- scripts/install_xcode.sh | 24 +- scripts/linux/pc/libsecret-1.pc | 15 - scripts/windows/replace_asset_links.sh | 20 - tool/verify_bom.dart | 78 +- 20 files changed, 293 insertions(+), 1325 deletions(-) delete mode 100644 integration_test/mwc_ffi_test.dart delete mode 100644 integration_test/mwc_ffi_test_service.dart delete mode 100644 integration_test/mwc_test_result.dart delete mode 100755 scripts/install_nixos_build_tools.sh delete mode 100644 scripts/linux/pc/libsecret-1.pc delete mode 100755 scripts/windows/replace_asset_links.sh diff --git a/.gitignore b/.gitignore index 7151970281..f3393861e8 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ lib/wl_gen/generated/ /tool/build bm/results/ /.cargo/config.toml +/scripts/linux/pc/ # Nix dev shell: generated wrappers .nix-bin/ diff --git a/Makefile b/Makefile index 07456a0a8f..f537d2def3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # ============================================================================== -# Stack Wallet build helper (macOS). Wraps scripts/build_app.sh; see docs/building.md +# Stack Wallet build helper. Wraps scripts/build_app.sh; see docs/building.md # Usage: make VERSION=x.x.x BUILD_NUM=xxx # ============================================================================== @@ -15,23 +15,28 @@ DART := $(DART_BIN) APP_PROJECT_ROOT_DIR := $(CURDIR) # Platform detection from uname. PLATFORM ?= $(shell uname -s 2>/dev/null) -PUB_CACHE ?= $(APP_PROJECT_ROOT_DIR)/.pub-cache +# 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 -SKIP_NATIVE ?= 0 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 -MACOS_ENV_SET = MACOSX_DEPLOYMENT_TARGET=12.0 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 test-mwc build-android android-dev-key build-ios +.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:" @@ -77,6 +82,11 @@ 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!" @@ -149,7 +159,7 @@ sccache-setup: ## Opt-in: use sccache for the Rust/C builds run by native-asset @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 - @dart tool/verify_bom.dart + @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; } @@ -163,8 +173,8 @@ clean: ## Remove artifacts and fix permissions @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 $(PUB_CACHE)/git/ 2>/dev/null || true - @find $(PUB_CACHE)/git/ -type d \( -name "build" -o -name "target" \) -path "*flutter_lib*" -exec rm -rf {} + 2>/dev/null || true + @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 --- @@ -172,6 +182,8 @@ clean: ## Remove artifacts and fix permissions 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 @@ -185,11 +197,8 @@ clean-native: ## Remove only the native-asset build outputs (forces the Rust/Go/ @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) - @# pubspec.yaml is generated; build_app.sh only regenerates it from the template when unchanged, - @# so a previous PREBUILTS setting would otherwise stick. - @rm -f pubspec.yaml @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 stack_wallet -s $(if $(filter 1,$(PREBUILTS)),-d) + @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 @@ -197,14 +206,6 @@ build-macos: check-reqs-macos check-sdk-macos ## Build macOS release (same steps @env $(MACOS_ENV_UNSET) $(FLUTTER) build macos --release -test-mwc: ## Run MWC FFI integration test on macOS (assumes prior `make build-macos`) - @# Flutter's first-launch helper rewrites MACOSX_DEPLOYMENT_TARGET=10.15; reassert 12.0. - @sed -i.bak -e "s/MACOSX_DEPLOYMENT_TARGET = 10\\.15;/MACOSX_DEPLOYMENT_TARGET = 12.0;/g" -e "s/MACOSX_DEPLOYMENT_TARGET = 11\\.0;/MACOSX_DEPLOYMENT_TARGET = 12.0;/g" macos/Runner.xcodeproj/project.pbxproj 2>/dev/null || true - @rm -f macos/Runner.xcodeproj/project.pbxproj.bak - @chmod -R u+w macos/Runner.xcodeproj macos 2>/dev/null || true - @env $(MACOS_ENV_UNSET) $(MACOS_ENV_SET) \ - $(FLUTTER) test integration_test/mwc_ffi_test.dart -d macos - diagnose-env-macos: ## Print macOS build env and tool resolution @echo "--- Toolchain diagnostics ---" @echo "flutter: $$(command -v $(FLUTTER) || echo missing)" @@ -228,6 +229,7 @@ diagnose-env-macos: ## Print macOS build env and tool resolution @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 @@ -247,25 +249,6 @@ build-linux: check-reqs ## Build Linux Release through Flutter native-assets hoo fi @echo "--- Building app..." @$(FLUTTER) pub get - @mkdir -p scripts/linux/pc - @printf '%s\n' \ - 'prefix=$(CURDIR)/scripts/linux/build/libsecret' \ - 'exec_prefix=$${prefix}' \ - 'libdir=$${prefix}/_build/libsecret' \ - 'includedir=$${prefix}' \ - '' \ - 'Name: libsecret-1' \ - 'Description: GObject bindings for Secret Service API' \ - 'Version: 0.21.4' \ - 'Libs: -L$${libdir} -lsecret-1' \ - 'Cflags: -I$${includedir} -I$${includedir}/_build' \ - > scripts/linux/pc/libsecret-1.pc - @if command -v podman >/dev/null 2>&1 || command -v docker >/dev/null 2>&1; then \ - : 'coinlib BuildKit git fetch fails on NixOS; scope the legacy-builder workaround here'; \ - DOCKER_BUILDKIT=0 $(FLUTTER) pub run coinlib:build_linux; \ - else \ - echo "[WARN] podman/docker not found; skipping coinlib:build_linux"; \ - fi @if [ "$(SYSTEM_DEPS)" = "1" ]; then \ USE_SYSTEM_SECURE_STORAGE_DEPS=1 PROTOC="$(PROTOC_PATH)" $(FLUTTER) build linux --release; \ else \ @@ -279,19 +262,19 @@ build-linux: check-reqs ## Build Linux Release through Flutter native-assets hoo 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 replaces the asset links with copies (needed on runners that cannot create symlinks). +# 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 - @# link_assets.sh (run by build_app.sh) cannot remove the real directories a previous copy run left behind. - @if [ "$(WINDOWS_COPY_ASSETS)" = "1" ]; then bash scripts/windows/replace_asset_links.sh --clean; fi - @cd scripts && echo yes | MWEBD_FETCH=1 ./build_app.sh -v $(VERSION) -b $(BUILD_NUM) -p windows -a $(APP_NAME) -d -s - @if [ "$(WINDOWS_COPY_ASSETS)" = "1" ]; then bash scripts/windows/replace_asset_links.sh; fi + @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 @@ -323,4 +306,3 @@ build-ios: check-sdk-macos ## Build iOS Runner.app without code signing (same st @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 8ba586ed7f..1977ed7fae 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ 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 setup's - Rust 1.99.1 selection is active. + - 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 diff --git a/bm/bench.sh b/bm/bench.sh index 8ceb9ad179..b53b2972da 100755 --- a/bm/bench.sh +++ b/bm/bench.sh @@ -70,7 +70,7 @@ 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 "Benchmark: ${LABEL}" | banner echo "Host: $HOST" echo "OS: $OS_NAME $OS_VER ($ARCH)" echo "Branch: $BRANCH" @@ -80,16 +80,16 @@ echo "Flags: ${MAKE_FLAGS:-(none)}" echo "" # Disk before -DISK_BEFORE=$(du -sk . 2>/dev/null | cut -f1) +DISK_BEFORE=$(du -sk . 2>/dev/null | cut -f1 || echo 0) # Clean unless warm if [ $WARM -eq 0 ]; then - echo "Cleaning" | banner + echo "Cleaning" | banner make clean 2>&1 | tail -1 fi # Build -echo "Building: make ${BUILD_TARGET} ${MAKE_FLAGS} " | banner +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. @@ -103,11 +103,11 @@ 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}') - SCC_HIT=$(sccache --show-stats 2>/dev/null | awk '/^Cache hits / {print $NF}') + 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) +DISK_AFTER=$(du -sk . 2>/dev/null | cut -f1 || echo 0) DISK_DELTA=$((DISK_AFTER - DISK_BEFORE)) # Write CSV header if new file @@ -124,7 +124,7 @@ if [ $RC -eq 0 ]; then else echo "✗ Build FAILED after ${WALL}s" fi -echo "Results appended: $OUTFILE" | banner +echo "Results appended: $OUTFILE" | banner # Show all runs echo "" diff --git a/bm/compare.py b/bm/compare.py index b41fb56c89..d43462502d 100755 --- a/bm/compare.py +++ b/bm/compare.py @@ -7,10 +7,9 @@ python3 bm/compare.py bm/results/ > report.md # markdown for bm/render.sh """ -import csv, sys, os, json +import csv, sys, os from collections import defaultdict from pathlib import Path -from statistics import mean, stdev, median PIPE = "│" diff --git a/bm/render.sh b/bm/render.sh index b27d83256f..3df37e33b0 100755 --- a/bm/render.sh +++ b/bm/render.sh @@ -56,6 +56,8 @@ else echo " ⚠ No PDF engine found. HTML ready: $HTML_FILE" fi -[ "$OPEN" -eq 1 ] && open "$PDF_FILE" 2>/dev/null || open "$HTML_FILE" 2>/dev/null || true +if [ "$OPEN" -eq 1 ]; then + open "$PDF_FILE" 2>/dev/null || open "$HTML_FILE" 2>/dev/null || true +fi -echo "Done." \ No newline at end of file +echo "Done." diff --git a/bm/run_all.sh b/bm/run_all.sh index 49bd674107..098b7c9188 100755 --- a/bm/run_all.sh +++ b/bm/run_all.sh @@ -36,6 +36,9 @@ 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 24d8d9cb4a..b6a1302ad3 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 libopencv-dev 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 libopencv-dev 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 ``` @@ -182,7 +182,7 @@ Note on Emulators: Only x86_64 emulators are supported, x86 emulators will not w #### 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` so the asset directories are copied instead of linked. The WSL2 route described at the top of this guide still works. +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 @@ -431,8 +431,8 @@ To test Tor usage, run Stack Wallet from Android Studio. Click the Flutter DevT 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`. It does not change the app's -name or bundle ID. Run `make help` for all targets. +`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 @@ -444,9 +444,10 @@ make build-macos VERSION=2.1.0 BUILD_NUM=210 PREBUILTS=0 # compile native asset 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, clones the pinned Flutter tag (3.47.4, the same patch release as the Nix shell; CI and the - rest of this guide use 3.47.2; builds were verified with both; override with `FLUTTER_VERSION` / `FLUTTER_DIR`), - installs the pinned Rust toolchain via rustup and adds the PATH entries to `~/.zshrc`. + 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. @@ -485,4 +486,4 @@ 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`). It needs `protoc`, Meson and Ninja; 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. +`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.nix b/flake.nix index 6fa8289522..b36de7dfa4 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,7 @@ let pkgs = import nixpkgs { inherit system; }; lib = pkgs.lib; + rustVersion = "1.99.1"; commonPackages = with pkgs; [ flutter @@ -40,10 +41,12 @@ opencv sysprof libsysprof-capture + patchelf + binutils ]); macPackages = lib.optionals pkgs.stdenv.isDarwin (with pkgs; [ - cocoapods + cocoapods libiconv autoconf automake @@ -60,27 +63,28 @@ 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 "1.99.1"; then + if ! rustup toolchain list | grep -q "${rustVersion}"; then echo "Initializing Rust toolchain (this happens only once)..." - rustup toolchain install --no-self-update 1.99.1 + rustup toolchain install --no-self-update ${rustVersion} fi - rustup default 1.99.1 - + # 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 1.99.1 + rustup target add aarch64-apple-darwin aarch64-apple-ios --toolchain ${rustVersion} fi if ! command -v cargo-ndk >/dev/null 2>&1; then @@ -121,7 +125,7 @@ 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++ @@ -131,8 +135,8 @@ export NM=/usr/bin/nm export RANLIB=/usr/bin/ranlib export STRIP=/usr/bin/strip - - export BINDGEN_EXTRA_CLANG_ARGS="-isysroot $SDKROOT" + + export BINDGEN_EXTRA_CLANG_ARGS="-isysroot $SDKROOT" export PROTOC="${pkgs.protobuf}/bin/protoc" mkdir -p .nix-bin @@ -141,7 +145,7 @@ 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 diff --git a/integration_test/mwc_ffi_test.dart b/integration_test/mwc_ffi_test.dart deleted file mode 100644 index 50bcb3b074..0000000000 --- a/integration_test/mwc_ffi_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'mwc_ffi_test_service.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('MWC FFI smoke', (tester) async { - await FFITestService.initialize(); - final ok = await FFITestService.runAllTests(); - expect( - ok, - isTrue, - reason: FFITestService.testResults - .where((r) => !r.passed) - .map((r) => '${r.name}: ${r.error}') - .join('\n'), - ); - }); -} diff --git a/integration_test/mwc_ffi_test_service.dart b/integration_test/mwc_ffi_test_service.dart deleted file mode 100644 index 5fd915d345..0000000000 --- a/integration_test/mwc_ffi_test_service.dart +++ /dev/null @@ -1,1028 +0,0 @@ -import 'dart:io'; -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_libmwc/lib.dart'; -import 'package:flutter_libmwc/mwc.dart' as lib_mwc; -import 'package:path_provider/path_provider.dart'; - -import 'mwc_test_result.dart'; - -/// Comprehensive FFI integration test service. -class FFITestService { - static final List _testResults = []; - static bool _isInitialized = false; - - /// Initialize the test framework. - static Future initialize() async { - if (_isInitialized) return; - - _logInfo('FFI Test Service initialized successfully'); - _isInitialized = true; - } - - /// Get all test results. - static List get testResults => List.unmodifiable(_testResults); - - /// Clear all test results. - static void clearResults() { - _testResults.clear(); - _logInfo('Test results cleared'); - } - - /// Run all FFI integration tests. - static Future runAllTests() async { - if (!_isInitialized) { - throw StateError('FFI Test Service not initialized'); - } - - clearResults(); - _logInfo('Starting comprehensive FFI integration test suite'); - - bool allPassed = true; - - // Phase 1: Environment and pre-flight validation. - allPassed &= await _runEnvironmentTests(); - - // Phase 2: Basic FFI functionality. - allPassed &= await _runBasicFFITests(); - - // Phase 3: Wallet management tests. - allPassed &= await _runWalletManagementTests(); - - // Phase 4: Transaction functionality tests. - allPassed &= await _runTransactionTests(); - - // Phase 5: Basic slatepack functionality tests. - allPassed &= await _runSlatepackTests(); - - // Phase 6: MWCMQS listener functionality tests. - allPassed &= await _runMWCMQSTests(); - - _logInfo( - 'Test suite completed. Overall result: ${allPassed ? "PASS" : "FAIL"}', - ); - return allPassed; - } - - /// Run environment validation tests. - static Future _runEnvironmentTests() async { - bool allPassed = true; - - // Test 1: Platform detection. - allPassed &= await _runTest( - 'Platform Detection', - 'Verify current platform is correctly detected', - () async { - final platform = Platform.operatingSystem; - final supportedPlatforms = [ - 'linux', - 'windows', - 'macos', - 'android', - 'ios', - ]; - - if (!supportedPlatforms.contains(platform)) { - throw TestException('Unsupported platform: $platform'); - } - - return 'Platform: $platform (supported)'; - }, - ); - - // Test 2: Library loading. - allPassed &= await _runTest( - 'Library Loading', - 'Verify native library can be loaded', - () async { - try { - final mnemonic = Libmwc.getMnemonic(); - if (mnemonic.isEmpty) { - throw TestException( - 'Library loaded but basic function returned empty result', - ); - } - return 'Library loaded successfully, basic function operational'; - } catch (e) { - throw TestException('Failed to load or call native library: $e'); - } - }, - ); - - return allPassed; - } - - /// Run basic FFI functionality tests. - static Future _runBasicFFITests() async { - bool allPassed = true; - - // Test 1: Mnemonic generation. - allPassed &= await _runTest( - 'Mnemonic Generation', - 'Test FFI mnemonic generation function', - () async { - final mnemonic = Libmwc.getMnemonic(); - final words = mnemonic.split(' '); - - if (words.length != 24) { - throw TestException( - 'Invalid mnemonic length: ${words.length} (expected 24)', - ); - } - - return 'Generated 24-word mnemonic successfully'; - }, - ); - - // Test 2: Address validation. - allPassed &= await _runTest( - 'Address Validation', - 'Test FFI address validation function', - () async { - // Test with known invalid address. - final invalidResult = Libmwc.validateSendAddress( - address: 'invalid_address', - ); - if (invalidResult) { - throw TestException('Invalid address incorrectly validated as valid'); - } - - return 'Address validation working correctly'; - }, - ); - - return allPassed; - } - - /// Run wallet management integration tests. - static Future _runWalletManagementTests() async { - bool allPassed = true; - - // Test 1: Wallet configuration validation. - allPassed &= await _runTest( - 'Wallet Configuration', - 'Test wallet configuration creation and validation', - () async { - final testConfig = await _getTestWalletConfig(); - if (testConfig.isEmpty) { - throw TestException('Failed to create test wallet configuration'); - } - - // Validate config contains required fields. - final configData = { - 'wallet_dir': '', - 'check_node_api_http_addr': '', - 'chain': '', - }; - for (final key in configData.keys) { - if (!testConfig.contains(key)) { - throw TestException('Missing required config field: $key'); - } - } - - return 'Test wallet configuration created and validated successfully'; - }, - ); - - // Test 2: Wallet initialization. - allPassed &= await _runTest( - 'Wallet Initialization', - 'Test new wallet creation via FFI', - () async { - final testMnemonic = Libmwc.getMnemonic(); - final testConfig = await _getTestWalletConfig(); - final testPassword = 'test_password_123'; - final walletName = - 'ffi_test_wallet_${DateTime.now().millisecondsSinceEpoch}'; - - try { - final result = await Libmwc.initializeNewWallet( - config: testConfig, - mnemonic: testMnemonic, - password: testPassword, - name: walletName, - ); - - if (result.toUpperCase().contains('ERROR')) { - throw TestException('Wallet initialization failed: $result'); - } - - return 'New wallet initialized successfully: $walletName'; - } catch (e) { - // Expected to potentially fail if wallet already exists or other issues. - if (e.toString().contains('already exists')) { - return 'Wallet initialization handled existing wallet correctly'; - } - rethrow; - } - }, - ); - - // Test 3: Wallet recovery. - allPassed &= await _runTest( - 'Wallet Recovery', - 'Test wallet recovery from mnemonic via FFI', - () async { - final testMnemonic = Libmwc.getMnemonic(); - final testConfig = await _getTestWalletConfig(); - final testPassword = 'recovery_test_123'; - final walletName = - 'ffi_recovery_test_${DateTime.now().millisecondsSinceEpoch}'; - - try { - await Libmwc.recoverWallet( - config: testConfig, - password: testPassword, - mnemonic: testMnemonic, - name: walletName, - ); - - return 'Wallet recovery from mnemonic completed successfully'; - } catch (e) { - // Expected to potentially fail in test environment. - if (e.toString().contains('directory') || - e.toString().contains('permission')) { - return 'Wallet recovery handled filesystem constraints correctly'; - } - throw TestException('Unexpected error in wallet recovery: $e'); - } - }, - ); - - // Test 4: Chain height retrieval. - allPassed &= await _runTest('Chain Height Query', 'Test chain height retrieval via FFI using remote MWC node', () async { - final testConfig = await _getTestWalletConfig(); - - try { - final height = await Libmwc.getChainHeight(config: testConfig); - - if (height < 0) { - throw TestException('Invalid chain height returned: $height'); - } - - // Mainnet should have a reasonable height (over 1 million blocks as of 2024). - if (height < 1000000) { - throw TestException( - 'Chain height seems too low for mainnet: $height', - ); - } - - return 'Chain height retrieved successfully: $height (mainnet)'; - } catch (e) { - final errorStr = e.toString(); - - // Handle specific error types with more detail. - if (errorStr.contains('FormatException') || - errorStr.contains('Invalid radix-10')) { - throw TestException( - 'Failed to parse chain height response: node may have returned an error message instead of height', - ); - } else if (errorStr.contains('connection') || - errorStr.contains('network') || - errorStr.contains('timeout')) { - throw TestException( - 'Network connection issue with remote node: ${errorStr.substring(0, 100)}...', - ); - } else if (errorStr.contains('Cannot m')) { - throw TestException( - 'Remote node connection failed - possibly network or SSL issue', - ); - } - - // Re-throw with more context. - throw TestException( - 'Chain height query failed: ${errorStr.substring(0, 100)}...', - ); - } - }); - - return allPassed; - } - - static Future _runTransactionTests() async { - bool allPassed = true; - - // Test 1: Transaction function availability. - allPassed &= await _runTest('Transaction Function Availability', 'Verify transaction functions are available via FFI', () async { - // Test that transaction functions exist and can be called. - // This validates the FFI bindings are working without requiring an actual wallet. - - try { - // First test: Validate address format function (should not panic). - final isValid = Libmwc.validateSendAddress(address: 'test@example.com'); - - // This should return false for a test address, but validates the function works. - if (isValid == true || isValid == false) { - return 'Transaction functions available: address validation working (result: $isValid)'; - } - - throw TestException('Address validation returned unexpected result'); - } catch (e) { - final errorStr = e.toString(); - - // Any error here indicates a problem with the FFI bindings themselves. - throw TestException( - 'Transaction function availability test failed: ${errorStr.substring(0, 100)}...', - ); - } - }); - - // Test 2: Transaction API Structure Validation. - allPassed &= await _runTest('Transaction API Structure', 'Validate transaction-related API structure and types', () async { - try { - // Test that we can create test configuration without errors. - final testConfig = await _getTestWalletConfig(); - - if (!testConfig.contains('wallet_dir') || - !testConfig.contains('check_node_api_http_addr') || - !testConfig.contains('chain')) { - throw TestException('Test configuration missing required fields'); - } - - // Test basic type validation. - const testAmount = 1000000; - const minConfirmations = 10; - - if (testAmount <= 0 || minConfirmations <= 0) { - throw TestException('Transaction parameter validation failed'); - } - - return 'Transaction API structure validation passed: config format and parameter types correct'; - } catch (e) { - throw TestException( - 'Transaction API structure test failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - // Test 3: Transaction Model Validation. - allPassed &= await _runTest('Transaction Model Validation', 'Validate transaction data models and type safety', () async { - try { - // Test transaction model structure by creating instances. - // This validates the Dart-side transaction types without calling FFI. - - final testAmount = 1500000; // 0.0015 MWC. - final testAddress = 'test_user@mwcmqs.example.com'; - final testNote = 'FFI integration test transaction'; - - // Validate parameter constraints. - if (testAmount <= 0) { - throw TestException('Transaction amount validation failed'); - } - - if (testAddress.isEmpty || !testAddress.contains('@')) { - throw TestException('Transaction address validation failed'); - } - - if (testNote.length > 500) { - throw TestException('Transaction note length validation failed'); - } - - return 'Transaction model validation passed: amount=$testAmount, address format validated, note length OK'; - } catch (e) { - throw TestException( - 'Transaction model validation failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - // Test 4: Transaction Error Code Validation. - allPassed &= await _runTest('Transaction Error Handling', 'Validate transaction error handling patterns', () async { - try { - // Test error message patterns that should be handled by transaction functions. - final expectedErrors = [ - 'wallet is not open', - 'WALLET_IS_NOT_OPEN', - 'insufficient funds', - 'invalid address', - 'network error', - 'connection timeout', - ]; - - // Validate we have error handling patterns for common issues. - for (final errorPattern in expectedErrors) { - if (errorPattern.isEmpty) { - throw TestException('Empty error pattern in validation list'); - } - } - - // Test amount boundary validation. - const minAmount = 1; - const maxAmount = 21000000 * 1000000000; // Max MWC supply in nanograms. - - if (minAmount >= maxAmount) { - throw TestException('Transaction amount boundary validation failed'); - } - - return 'Transaction error handling validation passed: ${expectedErrors.length} error patterns validated, amount boundaries correct'; - } catch (e) { - throw TestException( - 'Transaction error handling validation failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - return allPassed; - } - - /// Run basic slatepack functionality integration tests. - static Future _runSlatepackTests() async { - bool allPassed = true; - - // Test 1: Slatepack API Structure Validation. - allPassed &= await _runTest('Slatepack API Structure', 'Validate slatepack API structure and parameter types', () async { - try { - // Test basic slatepack parameter validation. - const testSlateJson = - '{"id":"test","tx":{"body":{"inputs":[],"outputs":[],"kernels":[]}}}'; - const testSlatepack = - 'BEGINSLATEPACK. test slatepack data .ENDSLATEPACK'; - const testRecipientAddress = 'test_user@mwcmqs.example.com'; - - // Validate parameter constraints for encoding. - if (testSlateJson.isEmpty) { - throw TestException('Slate JSON validation failed'); - } - - if (!testSlateJson.contains('id')) { - throw TestException('Slate JSON structure validation failed'); - } - - // Validate slatepack format structure. - if (!testSlatepack.contains('BEGINSLATEPACK') || - !testSlatepack.contains('ENDSLATEPACK')) { - throw TestException('Slatepack format validation failed'); - } - - // Validate address format. - if (testRecipientAddress.isEmpty || - !testRecipientAddress.contains('@')) { - throw TestException('Recipient address format validation failed'); - } - - return 'Slatepack API structure validation passed: slate format, slatepack format, and address format correct'; - } catch (e) { - throw TestException( - 'Slatepack API structure test failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - // Test 2: Slatepack Format Validation. - allPassed &= await _runTest('Slatepack Format Validation', 'Validate slatepack format patterns and structure', () async { - try { - // Test various slatepack format patterns. - final validFormats = [ - 'BEGINSLATEPACK. test data .ENDSLATEPACK', - 'BEGINSLATEPACK.\nencoded_data_here\n.ENDSLATEPACK', - 'BEGINSLATEPACK. VGVzdCBkYXRh .ENDSLATEPACK', // Base64-like. - ]; - - final invalidFormats = [ - 'INVALID FORMAT', - 'BEGINSLATEPACK without end', - 'missing begin ENDSLATEPACK', - '', - 'BEGINSLATE PACK. test .ENDSLATEPACK', // Wrong format. - ]; - - // Validate all valid formats pass basic structure check. - for (final format in validFormats) { - if (!format.contains('BEGINSLATEPACK') || - !format.contains('ENDSLATEPACK')) { - throw TestException( - 'Valid slatepack format failed validation: $format', - ); - } - } - - // Validate all invalid formats fail basic structure check. - for (final format in invalidFormats) { - if (format.contains('BEGINSLATEPACK') && - format.contains('ENDSLATEPACK')) { - throw TestException( - 'Invalid slatepack format passed validation: $format', - ); - } - } - - return 'Slatepack format validation passed: ${validFormats.length} valid formats recognized, ${invalidFormats.length} invalid formats rejected'; - } catch (e) { - throw TestException( - 'Slatepack format validation failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - // Test 3: Slatepack Roundtrip (compact, unencrypted). - allPassed &= await _runTest('Slatepack Roundtrip (compact)', 'Encode compact slate JSON to slatepack and decode back via FFI', () async { - try { - // Construct a minimal compact slate JSON. - // Use version 3 + compact_slate flag to satisfy current lib expectations. - const compactSlateJson = - '{\n' - ' "version_info": {\n' - ' "orig_version": 3,\n' - ' "version": 3,\n' - ' "block_header_version": 1\n' - ' },\n' - ' "id": "0436430c-2b02-624c-2032-570501212b00",\n' - ' "sta": "S1",\n' - ' "num_participants": 2,\n' - ' "amount": "1000000000",\n' - ' "fee": "1000000",\n' - ' "height": "0",\n' - ' "lock_height": "0",\n' - ' "ttl_cutoff_height": "1440",\n' - ' "payment_proof": null,\n' - ' "compact_slate": true,\n' - ' "participant_data": []\n' - '}'; - - // Encode to slatepack (unencrypted) — recipientAddress null. - final enc = await Libmwc.encodeSlatepack( - slateJson: compactSlateJson, - recipientAddress: null, - encrypt: false, - ); - - // Basic format assertions. - if (!enc.slatepack.contains('BEGINSLATEPACK') || - !enc.slatepack.contains('ENDSLATEPACK')) { - throw TestException('Encoded slatepack missing BEGIN/END markers'); - } - if (enc.wasEncrypted) { - throw TestException('Unencrypted encode reported as encrypted'); - } - - // Decode back to JSON. - final dec = await Libmwc.decodeSlatepack(slatepack: enc.slatepack); - final decoded = jsonDecode(dec.slateJson) as Map; - final decodedId = decoded['id'] as String?; - final versionInfo = decoded['version_info'] as Map?; - if (decodedId != '0436430c-2b02-624c-2032-570501212b00') { - throw TestException( - 'Decoded slate id mismatch or missing: $decodedId', - ); - } - if (versionInfo == null || versionInfo['version'] != 3) { - throw TestException('Decoded slate version is not v3'); - } - - // Verify encryption detection helper. - final isEncrypted = await Libmwc.isSlatepackEncrypted(enc.slatepack); - if (isEncrypted) { - throw TestException( - 'isSlatepackEncrypted returned true for unencrypted slatepack', - ); - } - - return 'Roundtrip succeeded; id=$decodedId; v4 compact; markers present; not encrypted'; - } catch (e) { - throw TestException('Slatepack roundtrip (compact) failed: $e'); - } - }); - - // Test 4: Slatepack Encryption Requirements (expected failure without wallet context). - allPassed &= await _runTest( - 'Slatepack Encryption Requirements', - 'Verify encrypted encode requires wallet context and recipient', - () async { - try { - bool threw = false; - try { - await Libmwc.encodeSlatepack( - slateJson: '{"id":"abc","version_info":{"version":3,"block_header_version":1}}', - recipientAddress: 'dummy@mwcmqs.mwc.mw', - encrypt: true, - wallet: null, // No wallet context in test environment - ); - } catch (e) { - threw = true; - final msg = e.toString(); - if (!msg.toLowerCase().contains('wallet') || - !msg.toLowerCase().contains('required')) { - throw TestException( - 'Unexpected error for encrypted encode without wallet: $msg', - ); - } - } - if (!threw) { - throw TestException( - 'Encrypted encode did not throw without wallet context', - ); - } - return 'Encrypted encode correctly requires wallet context'; - } catch (e) { - throw TestException('Encryption requirement validation failed: $e'); - } - }, - ); - - // Test 5: Slatepack Decode Invalid Format Handling. - allPassed &= await _runTest( - 'Slatepack Decode Invalid Format', - 'Decode invalid slatepack and validate graceful handling', - () async { - try { - final bogus = 'NOT_A_SLATEPACK'; - String? slateJson; - Object? decodeError; - try { - final dec = await Libmwc.decodeSlatepack(slatepack: bogus); - slateJson = dec.slateJson; - } catch (e) { - decodeError = e; - } - // If the decoder throws, that's also acceptable as graceful failure. - if (decodeError != null) { - final es = decodeError.toString(); - final trunc = es.substring(0, es.length < 80 ? es.length : 80); - return 'Invalid slatepack decode threw as expected: $trunc...'; - } - // High-level API may not throw; validate empty slate JSON returned. - // Outside the catch above, so a failed assertion fails the test. - if (slateJson!.isNotEmpty) { - throw TestException( - 'Invalid slatepack returned non-empty slate JSON', - ); - } - return 'Invalid slatepack handled gracefully (empty slate JSON)'; - } catch (e) { - throw TestException('Invalid format handling failed: $e'); - } - }, - ); - - // Test 3: Slatepack Encoding Parameter Validation. - allPassed &= await _runTest('Slatepack Encoding Parameters', 'Validate slatepack encoding parameter handling', () async { - try { - // Test encoding parameter validation without calling actual FFI. - final testCases = [ - { - 'name': 'basic slate', - 'slateJson': '{"id":"test-123","version_info":{"version":3,"block_header_version":1}}', - 'encrypt': false, - 'recipientAddress': null, - }, - { - 'name': 'encrypted slate', - 'slateJson': '{"id":"test-456","version_info":{"version":3,"block_header_version":1}}', - 'encrypt': true, - 'recipientAddress': 'user@mwcmqs.example.com', - }, - ]; - - for (final testCase in testCases) { - final slateJson = testCase['slateJson'] as String; - final encrypt = testCase['encrypt'] as bool; - final recipientAddress = testCase['recipientAddress'] as String?; - - // Validate slate JSON structure. - if (!slateJson.contains('id')) { - throw TestException( - 'Test case ${testCase['name']} missing required slate ID', - ); - } - - // Validate encryption parameters. - if (encrypt && - (recipientAddress == null || recipientAddress.isEmpty)) { - throw TestException( - 'Test case ${testCase['name']} encryption requires recipient address', - ); - } - - // Validate address format if provided. - if (recipientAddress != null && !recipientAddress.contains('@')) { - throw TestException( - 'Test case ${testCase['name']} invalid recipient address format', - ); - } - } - - return 'Slatepack encoding parameter validation passed: ${testCases.length} test cases validated'; - } catch (e) { - throw TestException( - 'Slatepack encoding parameter validation failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - // Test 4: Slatepack Decoding Parameter Validation. - allPassed &= await _runTest('Slatepack Decoding Parameters', 'Validate slatepack decoding parameter handling and response structure', () async { - try { - // Test decoding parameter validation and expected response structure. - final testSlatepack = 'BEGINSLATEPACK. dGVzdCBkYXRh .ENDSLATEPACK'; - - // Validate slatepack format. - if (!testSlatepack.contains('BEGINSLATEPACK') || - !testSlatepack.contains('ENDSLATEPACK')) { - throw TestException('Test slatepack format validation failed'); - } - - // Define expected decode response structure. - final expectedFields = [ - 'slate_json', - 'sender', // nullable. - 'recipient', // nullable. - ]; - - // Validate we have proper validation for expected response fields. - for (final field in expectedFields) { - if (field.isEmpty) { - throw TestException('Empty expected field in validation list'); - } - } - - // Test decoding error scenarios. - final errorTestCases = [ - {'input': '', 'expectedError': 'empty slatepack'}, - {'input': 'INVALID FORMAT', 'expectedError': 'invalid format'}, - { - 'input': 'BEGINSLATEPACK. corrupted_data .ENDSLATEPACK', - 'expectedError': 'decoding error', - }, - ]; - - // Validate error cases are properly structured. - for (final testCase in errorTestCases) { - final input = testCase['input'] as String; - final expectedError = testCase['expectedError'] as String; - - if (input.isEmpty && expectedError != 'empty slatepack') { - throw TestException( - 'Error test case mismatch: empty input should expect empty slatepack error', - ); - } - } - - return 'Slatepack decoding parameter validation passed: ${expectedFields.length} response fields validated, ${errorTestCases.length} error cases structured'; - } catch (e) { - throw TestException( - 'Slatepack decoding parameter validation failed: ${e.toString().substring(0, 100)}...', - ); - } - }); - - return allPassed; - } - - /// Run MWCMQS listener functionality integration tests. - static Future _runMWCMQSTests() async { - bool allPassed = true; - - // Test 1: MWCMQS API Function Availability. - allPassed &= await _runTest('MWCMQS API Function Availability', 'Verify MWCMQS FFI functions are properly loaded and accessible', () async { - try { - // Test that we can access the MWCMQS FFI functions through the native library. - // Try to call the FFI functions with test parameters to verify they're accessible. - - // Verify the mwcMqsListenerStart function exists by trying to call it. - try { - final testWallet = '[test_handle]'; - final testConfig = '{"test":"config"}'; - // This will likely fail but confirms the function is accessible. - lib_mwc.mwcMqsListenerStart(testWallet, testConfig); - return 'MWCMQS FFI functions verified: mwcMqsListenerStart accessible and callable'; - } catch (functionError) { - // Expected to fail with invalid parameters, but function should be accessible. - if (functionError.toString().contains('NoSuchMethodError')) { - throw TestException( - 'MWCMQS start function not available: ${functionError.toString()}', - ); - } - return 'MWCMQS FFI functions verified: mwcMqsListenerStart accessible (failed with expected error: ${functionError.toString().substring(0, 50)}...)'; - } - } catch (e) { - throw TestException( - 'MWCMQS API function availability test failed: ${e.toString()}', - ); - } - }); - - // Test 2: MWCMQS Listener Configuration. - allPassed &= await _runTest( - 'MWCMQS Listener Configuration', - 'Test MWCMQS configuration JSON creation and validation', - () async { - try { - // Create a MWCMQS configuration. - final mwcmqsConfig = jsonEncode({ - 'mwcmqs_domain': 'mqs.mwc.mw', - 'mwcmqs_port': 443, - 'mwcmqs_use_ssl': true, - }); - - // Validate configuration can be parsed. - final configData = jsonDecode(mwcmqsConfig); - if (configData['mwcmqs_domain'] != 'mqs.mwc.mw' || - configData['mwcmqs_port'] != 443 || - configData['mwcmqs_use_ssl'] != true) { - throw TestException('MWCMQS configuration validation failed'); - } - - return 'MWCMQS configuration created and validated: $mwcmqsConfig'; - } catch (e) { - throw TestException( - 'MWCMQS configuration test failed: ${e.toString()}', - ); - } - }, - ); - - // Test 3: MWCMQS Listener Start/Stop API. - allPassed &= await _runTest('MWCMQS Listener Start/Stop API', 'Test MWCMQS listener API accessibility and parameter validation', () async { - try { - // Test that the MWCMQS functions are accessible and accept parameters correctly. - // We'll test with invalid parameters to avoid network calls that might crash. - - // Test listener start function accessibility. - try { - // Use clearly invalid parameters that should trigger a controlled error. - final invalidWallet = 'invalid_wallet_handle'; - final invalidConfig = '{"invalid": "config"}'; - - // This should fail gracefully with a parameter error, not crash. - final result = lib_mwc.mwcMqsListenerStart( - invalidWallet, - invalidConfig, - ); - - // If we get a result without crashing, that's unexpected but good. - return 'MWCMQS listener API accessible: start function callable, returned pointer ${result.address}'; - } catch (apiError) { - // We expect this to fail with invalid parameters, which is good. - // Check that it's a controlled error, not a crash. - final errorString = apiError.toString(); - - if (errorString.contains('invalid') || - errorString.contains('parameter') || - errorString.contains('format') || - errorString.contains('parse') || - errorString.contains('wallet') || - errorString.contains('config')) { - return 'MWCMQS listener API validated: start function accessible and properly validates parameters (error: ${errorString.substring(0, 60)}...)'; - } - - // If it's some other error, that's still validation that the function exists. - return 'MWCMQS listener API accessible: start function exists and callable (failed with: ${errorString.substring(0, 60)}...)'; - } - } catch (e) { - throw TestException('MWCMQS listener API test failed: ${e.toString()}'); - } - }); - - // Test 4: MWCMQS High-Level API Integration. - allPassed &= await _runTest('MWCMQS High-Level API Integration', 'Test high-level ListenerManager API for MWCMQS functionality', () async { - try { - // Test that Libmwc MWCMQS functions are accessible. - // We can't check if methods are null, so we'll try to call them. - try { - // Try to access the methods - this will throw if they don't exist. - final startMethod = Libmwc.startMwcMqsListener; - final stopMethod = Libmwc.stopMwcMqsListener; - - // If we get here, methods exist. - } catch (methodError) { - throw TestException( - 'Libmwc MWCMQS methods not available: ${methodError.toString()}', - ); - } - - // Test high-level API call structure. - try { - // This should fail gracefully since we don't have a real wallet/server. - Libmwc.startMwcMqsListener( - wallet: '[test_wallet_handle]', - mwcmqsConfig: jsonEncode({ - 'mwcmqs_domain': 'mqs.mwc.mw', - 'mwcmqs_port': 443, - 'mwcmqs_use_ssl': true, - }), - ); - - // If we get here, the API call structure is correct. - return 'MWCMQS high-level API integration validated: Libmwc methods accessible and callable'; - } catch (apiError) { - // Expected to fail without real wallet, but validates API structure. - if (apiError.toString().contains('handle') || - apiError.toString().contains('wallet') || - apiError.toString().contains('connection') || - apiError.toString().contains('invalid')) { - return 'MWCMQS high-level API structure validated (expected failure without real wallet): ${apiError.toString().substring(0, 80)}...'; - } - throw apiError; - } - } catch (e) { - throw TestException( - 'MWCMQS high-level API integration test failed: ${e.toString()}', - ); - } - }); - - return allPassed; - } - - /// Get test wallet configuration. - static Future _getTestWalletConfig() async { - final walletDir = await _getTestWalletDir(); - final config = { - 'wallet_dir': walletDir, - 'check_node_api_http_addr': - 'https://mwc713.mwc.mw:443', // Working remote node. - 'chain': 'mainnet', // Use mainnet since remote node is mainnet. - 'account': 'default', - }; - - return '{"wallet_dir":"${config['wallet_dir']}","check_node_api_http_addr":"${config['check_node_api_http_addr']}","chain":"${config['chain']}","account":"${config['account']}"}'; - } - - /// Run a single test with proper error handling and result tracking. - static Future _runTest( - String name, - String description, - Future Function() testFunction, - ) async { - _logInfo('Running test: $name'); - - final stopwatch = Stopwatch()..start(); - - try { - final result = await testFunction(); - stopwatch.stop(); - - _testResults.add( - TestResult( - name: name, - description: description, - passed: true, - duration: stopwatch.elapsed, - result: result, - ), - ); - - _logInfo('Test PASSED: $name (${stopwatch.elapsedMilliseconds}ms)'); - return true; - } catch (e, stackTrace) { - stopwatch.stop(); - - _testResults.add( - TestResult( - name: name, - description: description, - passed: false, - duration: stopwatch.elapsed, - error: e.toString(), - stackTrace: stackTrace.toString(), - ), - ); - - _logError('Test FAILED: $name - $e'); - return false; - } - } - - /// Log info message. - static void _logInfo(String message) { - final timestamp = DateTime.now().toIso8601String(); - debugPrint('[$timestamp] [INFO] $message'); - } - - /// Log error message. - static void _logError(String message) { - final timestamp = DateTime.now().toIso8601String(); - debugPrint('[$timestamp] [ERROR] $message'); - } - - /// Get appropriate test wallet directory for current platform. - static Future _getTestWalletDir() async { - if (Platform.isAndroid) { - return '/data/data/com.example.flutter_libmwc_example/files/ffi_test_wallets/'; - } else if (Platform.isIOS) { - // Use proper iOS Application Support directory instead of hardcoded path - final appSupportDir = await getApplicationSupportDirectory(); - return '${appSupportDir.path}/ffi_test_wallets/'; - } else if (Platform.isLinux) { - return '/tmp/flutter_libmwc_ffi_test_wallets/'; - } else if (Platform.isWindows) { - return r'C:\temp\flutter_libmwc_ffi_test_wallets\'; - } else if (Platform.isMacOS) { - return '/tmp/flutter_libmwc_ffi_test_wallets/'; - } else { - return '/tmp/flutter_libmwc_ffi_test_wallets/'; - } - } -} - -/// Exception thrown during testing. -class TestException implements Exception { - final String message; - - const TestException(this.message); - - @override - String toString() => 'TestException: $message'; -} diff --git a/integration_test/mwc_test_result.dart b/integration_test/mwc_test_result.dart deleted file mode 100644 index 27dbc2e1dd..0000000000 --- a/integration_test/mwc_test_result.dart +++ /dev/null @@ -1,28 +0,0 @@ -/// Represents the result of a single FFI integration test. -class TestResult { - final String name; - final String description; - final bool passed; - final Duration duration; - final String? result; - final String? error; - final String? stackTrace; - final DateTime timestamp; - - TestResult({ - required this.name, - required this.description, - required this.passed, - required this.duration, - this.result, - this.error, - this.stackTrace, - }) : timestamp = DateTime.now(); - - /// Get a summary string for this test result. - String get summary { - final status = passed ? 'PASS' : 'FAIL'; - final durationMs = duration.inMilliseconds; - return '$status: $name (${durationMs}ms)'; - } -} 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/download_xcode.sh b/scripts/download_xcode.sh index 6ddc23e2fb..e670be9bb1 100755 --- a/scripts/download_xcode.sh +++ b/scripts/download_xcode.sh @@ -95,6 +95,16 @@ if [[ "$TARGET_DIR" != *.app || "$TARGET_DIR" == "/" ]]; then 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 @@ -106,6 +116,28 @@ fi 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)" @@ -122,9 +154,9 @@ 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}." - if [[ "$(xcode-select -p 2>/dev/null || true)" != "${TARGET_DIR}/Contents/Developer" ]]; then - echo "Run to select it: sudo xcode-select -s \"${TARGET_DIR}/Contents/Developer\"" - fi + # 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." @@ -151,12 +183,37 @@ if [[ "$FREE_KB" -lt $((20 * 1024 * 1024)) ]]; then exit 1 fi -# 1. Resumable download +# 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 - echo "Downloading ${FILE_NAME} (resumable; torrent alternative: ${TORRENT_URL})..." - curl -fL -C - --retry 8 --retry-delay 5 --retry-all-errors --progress-bar -o "$XIP_PATH" "$DOWNLOAD_URL" -else - echo "Using cached archive: $XIP_PATH" + 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 @@ -209,15 +266,4 @@ $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. -if [[ $ASSUME_YES -eq 1 ]]; then - sudo xcode-select -s "${TARGET_DIR}/Contents/Developer" - sudo xcodebuild -license accept - sudo xcodebuild -runFirstLaunch - 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 +finish_install diff --git a/scripts/install_macos_build_tools.sh b/scripts/install_macos_build_tools.sh index e317a0e65b..ec78b1bbb5 100755 --- a/scripts/install_macos_build_tools.sh +++ b/scripts/install_macos_build_tools.sh @@ -64,22 +64,46 @@ add_rustup_to_path # 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 the app is built -# and tested against one pinned release. Install that tag from source instead. -FLUTTER_VERSION="${FLUTTER_VERSION:-3.47.4}" +# 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 [[ "$CURRENT_FLUTTER" == "$FLUTTER_VERSION" ]]; then - echo "Flutter $FLUTTER_VERSION already installed ($FLUTTER_BIN)." +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 @@ -93,17 +117,28 @@ else 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 -export PATH="$FLUTTER_DIR/bin:$PATH" + +# 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 -echo "Ensuring Rust 1.99.1 toolchain..." -rustup toolchain install 1.99.1 -rustup default 1.99.1 -rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --toolchain 1.99.1 >/dev/null 2>&1 || true +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 @@ -112,10 +147,10 @@ rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios --t 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 1.99.1 || true + 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 1.99.1 || true + | sh -s -- -y --no-modify-path --default-toolchain "$RUST_VERSION" || true fi fi @@ -141,8 +176,8 @@ else fi rustup --version -rustc --version -rustup run 1.99.1 rustc --version +rustup run "$RUST_VERSION" rustc --version +rustup run "$RUST_VERSION" cargo --version pod --version go version autoreconf --version | head -n 1 || true @@ -160,11 +195,14 @@ 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 || \ - echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> "$ZSHRC" -grep -qF "$FLUTTER_DIR/bin" "$ZSHRC" 2>/dev/null || \ - echo "export PATH=\"$FLUTTER_DIR/bin:\$PATH\"" >> "$ZSHRC" + 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 || \ - echo 'export PATH="/opt/homebrew/opt/rustup/bin:$HOME/.cargo/bin:$PATH"' >> "$ZSHRC" + 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_nixos_build_tools.sh b/scripts/install_nixos_build_tools.sh deleted file mode 100755 index d4b42bc80a..0000000000 --- a/scripts/install_nixos_build_tools.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ "$(uname -s)" != "Linux" ]]; then - echo "This installer is for Linux/NixOS only." - exit 1 -fi - -if ! command -v nix >/dev/null 2>&1; then - echo "Nix is required. Install it first: https://nixos.org/download/" - exit 1 -fi - -if ! command -v rustup >/dev/null 2>&1; then - echo "rustup is required. Install it first (for toolchain pinning): https://rustup.rs" - exit 1 -fi - -echo "Installing Nix profile packages..." -nix --extra-experimental-features "nix-command flakes" profile add \ - nixpkgs#direnv \ - nixpkgs#flutter \ - nixpkgs#go \ - nixpkgs#cmake \ - nixpkgs#ninja \ - nixpkgs#pkg-config \ - nixpkgs#gnumake \ - nixpkgs#gnused \ - nixpkgs#protobuf \ - nixpkgs#autoconf \ - nixpkgs#automake \ - nixpkgs#libtool \ - nixpkgs#clang || true - -echo "Ensuring Rust 1.99.1 toolchain..." -rustup toolchain install --no-self-update 1.99.1 -rustup default 1.99.1 -rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu --toolchain 1.99.1 >/dev/null 2>&1 || true - -echo "Installing optional Rust CLI build tools..." -cargo install cargo-ndk || true - -echo "Verifying toolchain..." -if command -v flutter >/dev/null 2>&1; then - flutter --version -else - echo "flutter not found in PATH." -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 -rustc --version -rustup run 1.99.1 rustc --version -go version -protoc --version || true -cmake --version | head -n 1 || true -pkg-config --version || true -autoreconf --version | head -n 1 || true -aclocal --version | head -n 1 || true - -echo "Done." diff --git a/scripts/install_xcode.sh b/scripts/install_xcode.sh index 18b1b54bfe..2d7d74d3d5 100755 --- a/scripts/install_xcode.sh +++ b/scripts/install_xcode.sh @@ -45,7 +45,17 @@ 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 - XCODE_APP="$(ls -d "$XCODE_APPS_DIR"/Xcode*.app 2>/dev/null | head -1 || true)" + 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 @@ -55,5 +65,13 @@ else sudo xcode-select -s "$XCODE_APP/Contents/Developer" fi -sudo xcodebuild -license accept 2>/dev/null || true -sudo xcodebuild -runFirstLaunch 2>/dev/null || true +# 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/pc/libsecret-1.pc b/scripts/linux/pc/libsecret-1.pc deleted file mode 100644 index 7da2e13f58..0000000000 --- a/scripts/linux/pc/libsecret-1.pc +++ /dev/null @@ -1,15 +0,0 @@ -prefix=/usr/local -libdir=${prefix}/lib/x86_64-linux-gnu -includedir=${prefix}/include - -exec_prefix=${prefix} -datarootdir=${prefix}/share -datadir=${datarootdir} -sysconfdir=${prefix}/etc - -Name: libsecret-1 -Description: GObject bindings for Secret Service API -Version: 0.20.5 -Requires: glib-2.0 >= 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/windows/replace_asset_links.sh b/scripts/windows/replace_asset_links.sh deleted file mode 100755 index 6b4cb433d4..0000000000 --- a/scripts/windows/replace_asset_links.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Replace the asset directory links created by link_assets.sh with real copies. -# With --clean it only removes them (links or copies) and copies nothing: link_assets.sh runs `rm -f` -# on each target, which cannot remove the real directories a previous run left behind. -# Needed where `mklink /D` is not allowed (e.g. the GitHub Actions windows-2022 runner user lacks -# SeCreateSymbolicLinkPrivilege), because Flutter's asset resolver cannot follow the broken reparse points. -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/../.." -clean_only=0 -[ "${1:-}" = "--clean" ] && clean_only=1 -for dirname in default_themes icon lottie in_app_logo_icons svg; do - target="assets/${dirname}" - source="asset_sources/${dirname}/stack_wallet" - if [ -e "$target" ] || [ -L "$target" ]; then - cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" - fi - [ "$clean_only" = "1" ] && continue - mkdir -p "$target" - cp -r "${source}/." "$target/" -done diff --git a/tool/verify_bom.dart b/tool/verify_bom.dart index 3ca18bc63e..20c8f27817 100644 --- a/tool/verify_bom.dart +++ b/tool/verify_bom.dart @@ -1,4 +1,5 @@ -// Checks the local toolchain and the pinned native plugins against stack-bom.toml. +// 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 @@ -62,9 +63,12 @@ Map> _readBom(String text) { Future _run(String exe, List args) async { try { - final r = await Process.run(exe, args); + // 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}${r.stderr}'.trim(); + return '${r.stdout}'.trim(); } on ProcessException { return null; } @@ -135,27 +139,42 @@ Future main(List args) async { final tc = bom['toolchain'] ?? {}; // Flutter / Dart - final fl = await _run('flutter', ['--version', '--machine']); + 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 info = - jsonDecode(fl.substring(fl.indexOf('{'))) 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'); + final start = fl.indexOf('{'); + final end = fl.lastIndexOf('}'); + if (start < 0 || end <= start) { + _fail('unexpected output from flutter --version --machine'); } else { - _fail('Dart $d does not match ${tc['dart_prefix']}.x'); + 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'); + } } } @@ -192,7 +211,8 @@ Future main(List args) async { _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', + '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']})'); @@ -206,12 +226,20 @@ Future main(List args) async { _warn('pubspec.lock not found, plugin pins not checked'); } else { for (final n in _pluginNames) { - if (!refs.containsKey(n)) continue; - if (pinned[n] == refs[n]) { - _ok('$n ${refs[n]!.substring(0, 7)}'); + 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 ${refs[n]!.substring(0, 7)} in pubspec.lock but ${pinned[n]?.substring(0, 7)} in $_bomFile (run --update)', + '$n is ${ref.substring(0, 7)} in pubspec.lock but ' + '${pin.substring(0, 7)} in $_bomFile (run --update)', ); } } From 8ded23ee8f9938005500fc72f5b04da2b8018bba Mon Sep 17 00:00:00 2001 From: parasew <9727+parasew@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:46:50 +0200 Subject: [PATCH 21/21] ci: serialize the macOS and iOS make-build jobs The fork has a single hosted macOS concurrency slot. Running the two jobs together leaves one queued until GitHub's 15-minute timeout cancels it. Run macOS first, then iOS, so each waits for the runner instead of competing. --- .github/workflows/make-build.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml index 08d0907606..1821eddce2 100644 --- a/.github/workflows/make-build.yaml +++ b/.github/workflows/make-build.yaml @@ -82,6 +82,10 @@ jobs: 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