diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a2695a91ba..74ee07ff15 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -17,8 +17,9 @@ on: required: true default: '1' -# Configure app uses -d to select pinned Epic/MWC prebuilts when included. -# Other native-assets packages, including frostdart, still build from source. +# Configure app uses -d for verified dnix prebuilts (Frost, Tor, Epic and MWC). +# Every app still builds dnssec_resolver with Rust 1.90.0; Stack Wallet also +# builds Xelis with Rust 1.99.1, and Stack Wallet/Campfire build Spark with C++. jobs: build-disclaimer: @@ -76,7 +77,9 @@ jobs: -p linux -a stack_wallet -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: @@ -85,6 +88,8 @@ jobs: - name: Build env: + # With -s (CI only) in Configure app, link the image's system + # jsoncpp and libsecret instead of building them. USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose @@ -143,7 +148,9 @@ jobs: -p android -a stack_wallet -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: @@ -231,6 +238,11 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -253,10 +265,8 @@ jobs: -b "${{ steps.ver.outputs.build_number }}" \ -p windows -a stack_wallet -d -s - # The Actions windows-2022 runner user lacks SeCreateSymbolicLinkPrivilege, - # so link_assets.sh's mklink /D calls either fail or produce broken reparse - # points that Flutter's asset resolver cannot traverse. Replace each of - # the five gitignored asset directories with real copies instead. + # Use real copies of the five gitignored asset directories instead of the + # directory links that link_assets.sh creates. - name: Replace asset symlinks with copies (CI workaround) run: | set -euo pipefail @@ -266,23 +276,23 @@ jobs: # Remove whatever link_assets.sh left (reparse point, symlink, or nothing). # cmd.exe rmdir on a junction/symlink removes the link, not the target. if [ -e "$target" ] || [ -L "$target" ]; then - cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + MSYS2_ARG_CONV_EXCL="/C${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ + cmd.exe /C rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" fi mkdir -p "$target" cp -r "${source}/." "$target/" done - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: CHANGE_NOW: ${{ secrets.CHANGE_NOW }} run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart - - name: Build secp256k1.dll for Windows - run: dart run coinlib:build_windows - - name: Build run: flutter build windows --release @@ -322,6 +332,11 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -340,7 +355,9 @@ jobs: -p macos -a stack_wallet -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: @@ -386,10 +403,10 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. - uses: dtolnay/rust-toolchain@master with: - toolchain: stable - targets: aarch64-apple-ios + toolchain: 1.90.0 - uses: subosito/flutter-action@v2 with: @@ -409,7 +426,9 @@ jobs: -p ios -a stack_wallet -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: @@ -472,7 +491,9 @@ jobs: -p linux -a campfire -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart campfire - name: Decode secrets env: @@ -481,6 +502,8 @@ jobs: - name: Build env: + # With -s (CI only) in Configure app, link the image's system + # jsoncpp and libsecret instead of building them. USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose @@ -539,7 +562,9 @@ jobs: -p linux -a stack_duo -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_duo - name: Decode secrets env: @@ -548,6 +573,8 @@ jobs: - name: Build env: + # With -s (CI only) in Configure app, link the image's system + # jsoncpp and libsecret instead of building them. USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose @@ -723,7 +750,9 @@ jobs: -p android -a campfire -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart campfire - name: Decode secrets env: @@ -811,6 +840,11 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -823,12 +857,6 @@ jobs: - name: Flutter doctor run: flutter doctor -v - - name: Install Rust toolchains - run: | - rustup toolchain install 1.85.1 - rustup toolchain install 1.99.1 - rustup default 1.99.1 - - name: Configure app run: | cd scripts @@ -844,23 +872,23 @@ jobs: target="assets/${dirname}" source="asset_sources/${dirname}/campfire" if [ -e "$target" ] || [ -L "$target" ]; then - cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + MSYS2_ARG_CONV_EXCL="/C${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ + cmd.exe /C rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" fi mkdir -p "$target" cp -r "${source}/." "$target/" done - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart campfire - name: Decode secrets env: CHANGE_NOW: ${{ secrets.CHANGE_NOW }} run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart - - name: Build secp256k1.dll for Windows - run: dart run coinlib:build_windows - - name: Build run: flutter build windows --release @@ -900,6 +928,11 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -909,12 +942,6 @@ jobs: with: go-version: '1.24.13' - - name: Install Rust toolchains - run: | - rustup toolchain install 1.85.1 - rustup toolchain install 1.99.1 - rustup default 1.99.1 - - name: Configure app run: | cd scripts @@ -924,7 +951,9 @@ jobs: -p macos -a campfire -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart campfire - name: Decode secrets env: @@ -970,10 +999,10 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. - uses: dtolnay/rust-toolchain@master with: - toolchain: stable - targets: aarch64-apple-ios + toolchain: 1.90.0 - uses: subosito/flutter-action@v2 with: @@ -984,14 +1013,6 @@ jobs: with: go-version: '1.24.13' - - name: Install additional Rust toolchains - run: | - rustup toolchain install 1.85.1 - rustup toolchain install 1.99.1 - rustup default 1.99.1 - rustup target add aarch64-apple-ios --toolchain 1.99.1 - rustup target add x86_64-apple-ios --toolchain 1.99.1 - - name: Configure app run: | cd scripts @@ -1001,7 +1022,9 @@ jobs: -p ios -a campfire -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart campfire - name: Decode secrets env: @@ -1181,7 +1204,9 @@ jobs: -p android -a stack_duo -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_duo - name: Decode secrets env: @@ -1269,6 +1294,11 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -1281,12 +1311,6 @@ jobs: - name: Flutter doctor run: flutter doctor -v - - name: Install Rust toolchains - run: | - rustup toolchain install 1.85.1 - rustup toolchain install 1.99.1 - rustup default 1.99.1 - - name: Configure app run: | cd scripts @@ -1302,23 +1326,23 @@ jobs: target="assets/${dirname}" source="asset_sources/${dirname}/stack_duo" if [ -e "$target" ] || [ -L "$target" ]; then - cmd.exe /c rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" + MSYS2_ARG_CONV_EXCL="/C${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ + cmd.exe /C rmdir "$(cygpath -w "$target")" 2>/dev/null || rm -rf "$target" fi mkdir -p "$target" cp -r "${source}/." "$target/" done - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_duo - name: Decode secrets env: CHANGE_NOW: ${{ secrets.CHANGE_NOW }} run: echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart - - name: Build secp256k1.dll for Windows - run: dart run coinlib:build_windows - - name: Build run: flutter build windows --release @@ -1358,6 +1382,11 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -1367,14 +1396,6 @@ jobs: with: go-version: '1.24.13' - - name: Install Rust toolchains - run: | - rustup toolchain install 1.85.1 - rustup toolchain install 1.99.1 - rustup default 1.99.1 - rustup target add aarch64-apple-darwin --toolchain 1.99.1 - cargo install cargo-lipo - - name: Configure app run: | cd scripts @@ -1384,7 +1405,9 @@ jobs: -p macos -a stack_duo -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_duo - name: Decode secrets env: @@ -1430,10 +1453,10 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT + # OpenAlias/DNSSEC builds from source in every app, including -d. - uses: dtolnay/rust-toolchain@master with: - toolchain: stable - targets: aarch64-apple-ios + toolchain: 1.90.0 - uses: subosito/flutter-action@v2 with: @@ -1444,14 +1467,6 @@ jobs: with: go-version: '1.24.13' - - name: Install additional Rust toolchains - run: | - rustup toolchain install 1.85.1 - rustup toolchain install 1.99.1 - rustup default 1.99.1 - rustup target add aarch64-apple-ios --toolchain 1.99.1 - rustup target add x86_64-apple-ios --toolchain 1.99.1 - - name: Configure app run: | cd scripts @@ -1461,7 +1476,9 @@ jobs: -p ios -a stack_duo -d -s - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_duo - name: Decode secrets env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 649aaa6b43..25dcc8495d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,16 @@ name: Test on: [pull_request] jobs: + windows-build-scripts: + runs-on: windows-2022 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Test Windows prebuild paths and stubs + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/prebuild_windows_test.ps1 + linux-release-bundle: runs-on: ubuntu-24.04 permissions: @@ -33,7 +43,8 @@ jobs: fetch-depth: 0 submodules: recursive - # -d enables the pinned Epic/MWC native-assets prebuilts for flutter test. + # -d selects verified dnix prebuilts for Frost, Tor, Epic and MWC. + # -s (CI only) skips building jsoncpp and libsecret; the image has them. - name: Configure app (prebuilt native assets) run: | cd scripts @@ -56,7 +67,9 @@ jobs: dart format --output=none --set-exit-if-changed $FILES - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: @@ -95,6 +108,18 @@ jobs: # run: flutter analyze - name: Test build scripts run: dart scripts/tests/build_input_test.dart + - name: Test native build configuration + run: dart scripts/tests/native_build_config_test.dart + - name: Test native settings and dependency contracts + run: | + dart scripts/tests/configure_native_assets_test.dart + dart scripts/tests/native_dependency_contract_test.dart + - name: Test build prerequisites + run: dart scripts/tests/build_environment_test.dart + - name: Test Windows secp256k1 helper + run: dart scripts/tests/windows_secp_build_test.dart + - name: Test Xcode setup helper + run: dart scripts/tests/xcode_setup_test.dart - name: Test wallet creation launcher run: dart scripts/tests/wallet_creation_runner_test.dart - name: Test diff --git a/Dockerfile b/Dockerfile index b90b15498b..b2b19812c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsecret-1-dev libssl-dev libtss2-dev \ ocl-icd-opencl-dev opencl-headers valac zlib1g-dev \ g++-aarch64-linux-gnu gcc-aarch64-linux-gnu \ - g++-mingw-w64-x86-64 gcc-mingw-w64-x86-64 \ openjdk-21-jdk-headless \ && rm -rf /var/lib/apt/lists/* @@ -26,16 +25,13 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* -ENV RUSTUP_HOME=/usr/local/rustup \ - CARGO_HOME=/usr/local/cargo \ - PATH=/usr/local/cargo/bin:$PATH +# Keep Rustup on PATH. Hooks install pinned toolchains in the runtime HOME; +# hooks_runner does not forward custom RUSTUP_HOME/CARGO_HOME settings. +ENV PATH=/usr/local/cargo/bin:$PATH RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain 1.99.1 --profile minimal --no-modify-path \ - && rustup install 1.85.1 1.71.0 stable --profile minimal \ - && rustup target add x86_64-unknown-linux-gnu --toolchain 1.99.1 \ - && cargo install cargo-ndk \ - && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" + | env RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo \ + sh -s -- -y --default-toolchain none --profile minimal --no-modify-path ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 @@ -93,10 +89,11 @@ RUN git clone --depth 1 --branch 3.47.2 https://github.com/flutter/flutter.git " RUN git config --system --add safe.directory '*' -RUN flutter --version && rustc --version && cargo --version && node --version && go version +RUN flutter --version && rustup --version && node --version && go version -# Android-only image: no Linux/Windows cross-compilers, no OpenCV/OpenCL, single Rust toolchain with android targets +# Android-only image: no Linux cross-compilers or OpenCV/OpenCL; +# Rust toolchains and targets are installed by hooks at runtime. FROM ubuntu:24.04 AS android ENV DEBIAN_FRONTEND=noninteractive \ @@ -113,17 +110,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openjdk-21-jdk-headless \ && rm -rf /var/lib/apt/lists/* -ENV RUSTUP_HOME=/usr/local/rustup \ - CARGO_HOME=/usr/local/cargo \ - PATH=/usr/local/cargo/bin:$PATH +# Keep Rustup on PATH. Hooks install pinned toolchains in the runtime HOME; +# hooks_runner does not forward custom RUSTUP_HOME/CARGO_HOME settings. +ENV PATH=/usr/local/cargo/bin:$PATH RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain 1.99.1 --profile minimal --no-modify-path \ - && rustup target add \ - aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \ - --toolchain 1.99.1 \ - && cargo install cargo-ndk \ - && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" + | env RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo \ + sh -s -- -y --default-toolchain none --profile minimal --no-modify-path ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 @@ -181,7 +174,7 @@ RUN git clone --depth 1 --branch 3.47.2 https://github.com/flutter/flutter.git " RUN git config --system --add safe.directory '*' -RUN flutter --version && rustc --version && cargo --version && go version +RUN flutter --version && rustup --version && go version # Image for flutter test (no Android SDK or cross-compilers) @@ -202,14 +195,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libjsoncpp-dev liblzma-dev libsecret-1-dev libssl-dev \ && rm -rf /var/lib/apt/lists/* -# Epic/MWC use prebuilts; frostdart still compiles through its native-assets hook. -ENV RUSTUP_HOME=/usr/local/rustup \ - CARGO_HOME=/usr/local/cargo \ - PATH=/usr/local/cargo/bin:$PATH +# Keep Rustup on PATH. Hooks install pinned toolchains in the runtime HOME; +# hooks_runner does not forward custom RUSTUP_HOME/CARGO_HOME settings. +ENV PATH=/usr/local/cargo/bin:$PATH RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain 1.99.1 --profile minimal --no-modify-path \ - && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" + | env RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo \ + sh -s -- -y --default-toolchain none --profile minimal --no-modify-path ENV PATH=/usr/local/go/bin:$PATH @@ -229,4 +221,4 @@ RUN git clone --depth 1 --branch 3.47.2 https://github.com/flutter/flutter.git " RUN git config --system --add safe.directory '*' -RUN flutter --version && go version +RUN flutter --version && rustup --version && go version diff --git a/docs/building.md b/docs/building.md index ab99a2e8e5..83202e9bc9 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,193 +1,382 @@ # Building -Here you will find instructions on how to install the necessary tools for building and running the app. - -## Prerequisites - -- Xelis requires Flutter 3.47 or newer and Rustup. Native Assets builds the - native wallet library and installs its pinned Rust toolchain automatically. - Android builds require NDK r28 or newer. - -- The only OS supported for building Android and Linux desktop is Ubuntu 24.04. Windows builds require using Ubuntu 24.04 on WSL2. macOS builds for itself and iOS. Advanced users may also be able to build on other Debian-based distributions like Linux Mint. -- Android setup ([Android Studio](https://developer.android.com/studio) and subsequent dependencies) -- 100 GB of storage -- Install go: [https://go.dev/doc/install](https://go.dev/doc/install) +Use Flutter **3.47.2**. Run `flutter doctor` after installing Flutter and the +SDKs for your target. The build hosts covered here are Ubuntu 24.04 for Linux +and Android, Apple Silicon macOS for macOS and iOS, and native Windows for +Windows. Allow at least 100 GB of free space; source builds populate a Nix +store as well as Flutter and native compiler caches. + +## Native build modes + +`scripts/build_app.sh` generates `pubspec.yaml` and the platform configuration. +By default it selects **source** builds for the dnix packages. Add **`-d`** to +use their verified prebuilts instead: + +| App | dnix packages | +|---|---| +| Stack Wallet | `frostdart`, `tor_ffi_plugin`, `flutter_libepiccash`, `flutter_libmwc` | +| Stack Duo | `frostdart`, `tor_ffi_plugin` | +| Campfire | `tor_ffi_plugin` | + +Source mode builds with each package's pinned Nix recipe and checks the result +against the release hashes in that package's `dnix.lock`. Prebuilt mode downloads +the release library and checks it against the same hashes. A build or hash +failure stops the hook. Source mode can reuse outputs already in the Nix store; +it does not download a second copy of the release just to compare hashes. + +The package Git revisions in the pubspec template and `pubspec.lock` pin the +recipes and expected hashes. **Stack Wallet does not need its own `dnix.lock` +or flake.** Updating a package means updating its Git revision and resolving +`pubspec.lock`; there are no app-level prebuilt manifest URLs to maintain. + +The generated settings look like this, with entries for all four packages. +Settings for packages absent from the selected app are ignored: + +```yaml +hooks: + user_defines: + frostdart: + build: source +``` + +Rerun `build_app.sh` to switch modes. It regenerates `pubspec.yaml`, so keep +permanent changes in `scripts/app_config/templates/pubspec.template.yaml`. +Configuration rejects package-local `.dnix-build` overrides in enabled packages: +dnix gives those files precedence over the app's settings, and `dev` mode skips +hash verification. Remove the reported overrides and run `flutter clean` before +reconfiguring. After changing local dependency overrides or resolving packages +separately, rerun `dart tool/check_native_overrides.dart ` before building. +This guard checks the selected Git pins and existing package resolution; an old +resolution may need refreshing too. Direct Flutter commands do not run the guard. +See [dnix build modes](https://github.com/cypherstack/dnix/blob/main/doc/configuration.md#build-modes). + +Normal builds use the pinned Git packages. If you explicitly override a package +with a local checkout, that package's `dnix.lock` must be present for verified +builds. Without it, dnix falls back to `dev` and skips hash verification even +when source or prebuilt mode is selected. The override guard above does not +check for missing locks. With `-d`, a checkout that keeps its `dnix.lock` gets +that lock's release library, so local native changes are not built; remove the +lock to build them in `dev` mode. + +### Which tools are still required? + +| Component | Requirements | +|---|---| +| The four dnix packages, source mode | [Nix](https://nixos.org/download/); on Windows, Nix inside WSL2; on a Mac, Xcode **27.0** for every target, including `flutter test`, as pinned by the packages | +| The four dnix packages, `-d` | No Nix or package-specific Rust/C toolchain needed for these libraries | +| Flutter and platform plugins | Target SDKs and the platform's C/C++ build tools in either mode | +| OpenAlias / DNSSEC (every app) | Rustup on the Flutter host in both modes; `dnssec_resolver` builds from source with Rust **1.90.0** | +| Xelis (Stack Wallet) | Rustup; its hook installs Rust **1.99.1** and the required target; Android needs NDK r28 or newer | +| Spark (Stack Wallet and Campfire) | CMake and a C++ compiler; its native-assets hook still builds from source | +| Coinlib secp256k1 | A separate platform build, described below | +| MWEB (Stack Wallet) | Go; CI uses **1.24.13**. The Windows executable has a separate WSL build or prebuilt download | +| Linux secure storage | The dependencies to build libsecret and JsonCPP | + +`-d` applies to the dnix packages, not every native library in the application. +Their old host Rust toolchain lists, `cbindgen`, `cargo-lipo`, and manually built +Epic/MWC/Tor dependencies are no longer required. **Keep Rustup for every app**: +unconditional `cs_openalias` depends on the source-built `dnssec_resolver`. +Stack Wallet also needs it for Xelis. These hooks install their pinned toolchains +automatically. The optional Isar source build (`-f`) separately uses Rust +**1.99.1**, installing it through Rustup if missing without changing your default +toolchain. Nix supplies the four dnix packages' own pinned toolchains, +independently of the Rust installed on the host. + +The macOS app deployment target is now **12.0** to accommodate the native +libraries. iOS remains 15.0, and the current Flutter Android minimum is API 24. ## Linux host -The following instructions are for building and running on a Linux host. Alternatively, see the [Mac](#mac-host) and/or [Windows](#windows-host) section. This entire section (except for the Android Studio section) needs to be completed in WSL if building on a Windows host. - -### Flutter -Install Flutter 3.47.2 by [following their guide](https://docs.flutter.dev/get-started/install/linux/desktop?tab=download#install-the-flutter-sdk). Run `flutter doctor` in a terminal to confirm its installation. - -### Android Studio -Install Android Studio. Follow instructions here [https://developer.android.com/studio/install#linux](https://developer.android.com/studio/install#linux) or install via snap: -``` -# setup android studio -sudo apt install -y openjdk-11-jdk -sudo snap install android-studio --classic -``` - -Use `Tools > SDK Manager` and navigate to `Languages & Frameworks > Android SDK > SDK tools` to install: - - `SDK Tools > Android SDK command line tools` - - `SDK Tools > CMake` -and for Android builds, - - `SDK Tools > Android SDK (API 35)` - - `SDK Tools > NDK` - -Then in `File > Settings > Plugins`, install the **Flutter** and **Dart** plugins and restart the IDE. In `File > Settings > Languages & Frameworks > Flutter > Editor`, enable auto format on save to match the project's code style. If you have problems with the Dart SDK, make sure to run `flutter` in a terminal to download it (use `source ~/.bashrc` to update your environment variables if you're still using the same terminal from which you ran `setup.sh`). Run `flutter doctor` to install any missing dependencies and review and agree to any license agreements. +Install [Flutter](https://docs.flutter.dev/install/manual) 3.47.2 and +[Nix](https://nixos.org/download/) for source mode. Check `nix --version` from +the same environment that runs Flutter. dnix enables the Nix flake features +it needs when invoking Nix. -Make a Pixel 4 (API 30) x86_64 emulator with 2GB of storage space for emulation. +For Ubuntu 24.04 desktop builds, install: -The following *may* be needed for Android studio: -``` -sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386 +```sh +sudo apt-get update +sudo apt-get install build-essential clang cmake ninja-build pkg-config \ + git curl unzip xz-utils python3 libgtk-3-dev liblzma-dev libssl-dev \ + libjsoncpp-dev libsecret-1-dev ``` -### Build dependencies -Install basic dependencies -``` -sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm lld g++ gcc gperf python3-typogrify xsltproc valac gobject-introspection meson pipx libgcrypt20-dev libglib2.0-dev libsecret-1-dev -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 -``` +`build_app.sh` builds JsonCPP and libsecret, which also needs: -Install [Rust](https://www.rust-lang.org/tools/install) via [rustup.rs](https://rustup.rs), the required Rust toolchains, and `cargo-ndk` with command: -``` -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -source ~/.bashrc -rustup install 1.99.1 -rustup default 1.99.1 -cargo install cargo-ndk +```sh +sudo apt-get install meson libgirepository1.0-dev valac xsltproc \ + docbook-xsl libgcrypt20-dev libglib2.0-dev ``` -Android specific dependencies: -``` -sudo apt-get install libc6-dev-i386 -rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android -``` +Install [Rustup](https://rustup.rs/) for **all three apps**, including prebuilt +mode. You can preinstall DNSSEC's toolchain with `rustup toolchain install +1.90.0`. Stack Wallet also needs [Go](https://go.dev/doc/install) and Xelis' +Rust **1.99.1** toolchain, which its hook installs on first use. The Docker images +install Rustup only; hooks install toolchains under the runtime user's HOME. +Preinstalling them under custom image-only `RUSTUP_HOME`/`CARGO_HOME` paths does +not help because the hook runner does not forward those variables. -Linux desktop specific dependencies: -``` -sudo apt-get install clang cmake lld ninja-build pkg-config libgtk-3-dev liblzma-dev meson python3-pip libgirepository1.0-dev valac xsltproc docbook-xsl -pip3 install --upgrade 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 -``` +### Android -### Flutter -Install Flutter 3.47.2 by [following their guide](https://docs.flutter.dev/install/manual). +Install [Android Studio](https://developer.android.com/studio/install#linux) +and its SDK command-line tools, platform tools, CMake, and the SDK platform +required by Flutter. The app pins NDK **28.2.13676358** in the Android template; +install it through SDK Manager. Run `flutter doctor --android-licenses` and +`flutter doctor` to check the installation. Android source builds through dnix +on Linux require an x86_64 host. -Run `flutter doctor` in a terminal to confirm its installation. +Use a physical Android device or an x86_64 emulator; 32-bit x86 emulators are +not supported. Enable KVM for hardware-accelerated emulation on Linux. -### Clone the repository and initialize submodules -After installing the prerequisites listed above, download the code and init the submodules -``` -git clone https://github.com/cypherstack/stack_wallet.git -cd stack_wallet -git submodule foreach 'git fetch --tags' && git submodule update --init --recursive -``` +## Mac host -Build the secure storage dependencies in order to target Linux (not needed for Windows or other platforms): -``` -cd scripts/linux -./build_secure_storage_deps.sh -// when finished go back to the root directory -cd ../.. -``` +On an Apple Silicon Mac, install Flutter 3.47.2 and +[Nix](https://nixos.org/download/) for source mode. Check `nix --version` from +the same environment that runs Flutter. **Xcode 27.0 is required +for verified source builds of the Apple libraries** (release build **27A266a**); +a different version can produce different hashes. Prebuilt mode still needs Xcode to build the Flutter +app. Install 27.0 alongside your existing Xcode, under `/Applications` with a +name matching `Xcode*.app`. dnix normally finds the pinned version there; you +can keep a different stable Xcode selected system-wide. See the beta limitation +below when keeping multiple builds of 27.0 installed. -### Build secp256k1 -Coinlib requires a secp256k1 library to be built prior to running Stack Wallet. It can be built from within the root `stack_wallet` folder on a... - - Linux host for Linux targets: `dart run coinlib:build_linux` (requires [Docker](https://docs.docker.com/engine/install/ubuntu/) or [`podman`](https://podman.io/docs/installation)) - - Linux host for Windows targets: `dart run coinlib:build_windows_crosscompile` - - Windows host: `dart run coinlib:build_windows` - - WSL2 host: `dart run coinlib:build_wsl` - - macOS host: `dart run coinlib:build_macos` +### Install with xcodes -or by using `scripts/linux/build_secp256k1.sh` or `scripts/windows/build_secp256k1.bat`. +Install the [xcodes CLI](https://github.com/XcodesOrg/xcodes) from +[Homebrew core](https://formulae.brew.sh/formula/xcodes), then run our +standalone helper from the repository root: -### Run prebuild script +```sh +brew install xcodes +dart tool/setup_xcode.dart +``` + +Use plain `dart`, as shown, so setup does not run the application's native build +hooks. The helper opens a separate Terminal window, reuses an installed 27.0 or +downloads it with `xcodes install 27.0 --no-superuser --update`, then runs Apple's +first-launch setup, which accepts the Xcode license. Wait for **Setup complete** +in that window before building. Preview the commands with +`dart tool/setup_xcode.dart --print`. + +The helper finds `xcodes` on your current PATH before cleaning the environment, +including Mint, MacPorts, Nix, and globally configured mise/asdf shims. It selects +the **27A266a** release build. If another build of 27.0 is installed and the +release is missing, it gives manual side-by-side installation instructions: +xcodes can refuse to install two builds of the same version. Existing bundles +are preserved. The setup window waits for Return after displaying its result, +so you can copy the instructions even with a close-on-exit profile. + +The helper and preflight read `toolchains.xcode` and, when available, +`toolchains.xcodeBuild` from the packages' `dnix.json`. For the current packages, +which lack a build-number pin, `tool/native_dependency_compatibility.json` +records the reviewed release for each exact Git revision. Updating a revision +requires either an upstream build-number pin or a reviewed compatibility entry. +Preflight checks only packages enabled for the selected app; the standalone +setup helper checks all four packages. On a fresh checkout, metadata is fetched +from the pinned GitHub commits when it is absent from the pub cache; this also +applies to `--print`. + +The installer runs in a clean environment without shell startup files or build +commands. `xcodes` handles Apple account prompts; `sudo -k` prompts directly for +the administrator password and does not create a reusable sudo ticket. Dart +does not read either password. This separates the terminal and processes; it +is not an OS sandbox. Project-specific shim configuration is not forwarded; +use a globally configured xcodes installation or its executable directly on +PATH. `xcodes` may remember Apple account credentials in your Keychain according +to its own settings. + +### Install from Apple's developer website + +1. Sign in to [Apple Developer Downloads](https://developer.apple.com/download/all/?q=Xcode), + find the **Xcode 27.0 release**, and download its `.xip` archive. +2. Expand the archive in Finder. Rename the extracted app to `Xcode-27.0-27A266a.app` + **before** moving it into `/Applications`, preserving your existing Xcode. +3. Open a separate Terminal window and finish setup: + + ```sh + sudo -k /Applications/Xcode-27.0-27A266a.app/Contents/Developer/usr/bin/xcodebuild -runFirstLaunch + DEVELOPER_DIR=/Applications/Xcode-27.0-27A266a.app/Contents/Developer xcodebuild -version + ``` + + The first command installs Apple's components and accepts the Xcode license. + Confirm the second prints `Xcode 27.0` and `Build version 27A266a`, then close + that setup window. You can also run our Dart helper after moving the app; it reuses that installation. + +### Select Xcode for a build + +In your normal build terminal, select 27.0 for Flutter and Xcode commands in that +shell only. Substitute the path printed by the helper (`xcodes` may name the bundle `Xcode-27.0.0.app`): -Certain test wallet parameter and API key template files must be created in order to run Stack Wallet. These can be created by script as in -``` -cd scripts -./prebuild.sh -// when finished go back to the root directory -cd .. +```sh +export DEVELOPER_DIR=/Applications/Xcode-27.0-27A266a.app/Contents/Developer +xcodebuild -version +flutter doctor ``` -or manually by creating the files referenced in that script with the specified content. -### Build plugins -#### Build script: `build_app.sh` -The `build_app.sh` script is used to build the Stack Wallet and its family of applications. View the script's help message with `./build_app.sh -h` for more information on its usage. +Run configuration and Flutter build commands in this shell. `unset DEVELOPER_DIR` +returns it to the system default. For iOS simulation, install the required +simulator runtime from this Xcode's Settings > Components. -Options: +**Native hooks do not inherit `DEVELOPER_DIR`.** dnix checks the system-wide +Xcode selection first, then scans `/Applications/Xcode*.app`. The current +packages pin version 27.0 but do not yet pin `toolchains.xcodeBuild`, so a 27.0 +beta can be selected ahead of the release even after setup succeeds. The app's +source preflight rejects that case before changing configuration. Use `-d`, or +keep that beta outside the scanned paths and ensure it is not the system-wide +selection. An upstream package build-number pin is still needed to make this +selection unambiguous for direct Flutter builds too. Exporting `DEVELOPER_DIR` +does not fix it. Other native hooks such as Spark/MWEB also discover tools +independently of this export. - - `a `: Specify the application ID (required). Valid options are `stack_wallet` or `stack_duo`. - - `b `: Specify the build number in 123 (required). - - `p `: Specify the platform to build for (required). Valid options are `android`, `ios`, `macos`, `linux`, or `windows`. - - `v `: Specify the version of the application in 1.2.3 format (required). - - `i`: Optional flag to skip building crypto plugins. Useful for updating `pubspec.yaml` and white-labelling different apps with the same plugins. +### Other Mac tools -For example, -``` -./build_app.sh -a stack_wallet -p linux -v 2.1.0 -b 210 -``` +Install [Homebrew](https://brew.sh/) and the remaining native/plugin tools: -#### Building plugins for Android -> Warning: This will take a long time, please be patient -``` -cd scripts -./build_app.sh -a stack_wallet -p android +```sh +brew install autoconf automake libtool cmake cocoapods pkg-config ``` -#### Building plugins for Linux -``` -cd scripts -./build_app.sh -a stack_wallet -p linux -``` +Install Rustup for **every app**, and Go for Stack Wallet. You do not need to install +`cbindgen`, `cargo-lipo`, or the old Epic/MWC dependency list. Use +`flutter doctor` to check the Apple SDK and CocoaPods setup. iOS device builds +also need your usual signing configuration. -#### Building plugins and configure for Windows -*This step is only necessary inside WSL2 for building on a Windows host.* +## Windows host -Install dependencies like MXE: -``` -cd scripts/windows -./deps.sh -``` +Keep Flutter, Dart, and the app checkout on Windows. Run the configuration +script from **Git Bash**, then use native Windows Flutter to build the app. +Install: + +- [Flutter](https://docs.flutter.dev/install/manual) 3.47.2 and Git for Windows. +- [Visual Studio 2022](https://docs.flutter.dev/platform-integration/windows/setup) + with **Desktop development with C++**, CMake tools, and a Windows SDK. +- Windows Developer Mode for symlinks used by Flutter and the asset scripts. +- Rustup on Windows for every app's DNSSEC hook and Stack Wallet's Xelis hook. +- WSL2 and Nix inside its Linux distribution for dnix source mode. +- Go and `mingw-w64` in the default WSL distribution for Stack Wallet's MWEB + build, unless you set `MWEBD_FETCH=1` (see [MWEB and secp256k1](#mweb-and-secp256k1)). + +Follow Microsoft's [WSL installation guide](https://learn.microsoft.com/en-us/windows/wsl/install) +to install Ubuntu 24.04, then follow the [Nix installation instructions](https://nixos.org/download/) +inside that distribution. Check from PowerShell: + +```powershell +wsl.exe --list --verbose +wsl.exe -d Ubuntu-24.04 -- /nix/var/nix/profiles/default/bin/nix --version +``` + +Adjust the Nix path if using a different installation. dnix finds Nix on the +WSL distribution's PATH or its standard profile paths. It invokes `wsl.exe`, +translates paths, builds a Windows DLL in Nix, copies it back to Windows, and +verifies it before Flutter bundles it. A second Flutter/Dart installation +inside WSL and manual copying of these four libraries are unnecessary. + +By default, dnix uses the default WSL distribution. Select it persistently with +`wsl.exe --set-default Ubuntu-24.04`, or put +`export DNIX_WSL_DISTRIBUTION=Ubuntu-24.04` in your Git Bash profile. +`build_app.sh` renders that setting for every enabled dnix package on each run; +`-w Ubuntu-24.04` overrides it for one configuration. Editing only the generated +`pubspec.yaml` will not survive reconfiguration. Keep native checkouts in LF form and preserve +symlinks and executable permissions; filesystem differences can cause a hash +mismatch. See [dnix Windows source builds](https://github.com/cypherstack/dnix/blob/main/doc/configuration.md#windows-source-builds) +for checkout and environment details. + +### MWEB and secp256k1 + +Stack Wallet's Windows MWEB executable has a separate build path. By default, +its configuration helper uses Go and `x86_64-w64-mingw32-gcc` inside the default +WSL distribution. Install Go there (CI uses 1.24.13) and `sudo apt-get install +mingw-w64`. Alternatively, set `MWEBD_FETCH=1` when running `build_app.sh` to +fetch the existing MWEB release and check its published checksum. This setting +is independent of `-d` and dnix's `wsl_distribution`. + +In **both** dnix modes, `build_app.sh` builds secp256k1 unless `-i` is supplied. +The helper finds Visual Studio 2022's bundled CMake through `vswhere`, so Git +Bash does not need a Developer Command Prompt. It checks tools before changing +app configuration and stops on failed build commands. After `-i`, run: -Upgrade the version of cmake >= 3.31.6, the default version of ubuntu 24.04 (3.28.1) will be too low to build libepiccash. -You can use pip to install a specific version -``` -sudo apt remove cmake -pip install cmake==3.31.6 +```sh +dart scripts/windows/build_secp256k1.dart ``` -install go in WSL [https://go.dev/doc/install](https://go.dev/doc/install) (follow linux instructions) and ensure you have `mingw-w64` package installed to get the `x86_64-w64-mingw32-gcc` compiler. +The helper builds secp256k1 **v0.5.0** +(`e3a885d42a7800c1ccebad94ad1e2b82c4df5c65`) with MSVC. dnix's +hash-verification guarantee applies to its four packages, not secp256k1. Each +build recompiles the DLL; `-i` skips this step during configuration. The old MXE +setup and Epic-specific CMake upgrade are not needed for dnix. -go version should be at least 1.24 +## Configure and build -and use `scripts/build_app.sh` to build plugins: (see the [Build script section](#build-script-build_appsh) to understand the arguments) -``` -cd .. -./build_app.sh -a stack_wallet -p windows -v 2.4.4 -b 301 -``` +Clone the repository, then run the setup from its root: -### Running -#### Android -Plug in your android device or use the emulator available via Android Studio and then run the following commands: -``` -flutter pub get -flutter run android +```sh +git clone https://github.com/cypherstack/stack_wallet.git +cd stack_wallet +bash scripts/prebuild.sh ``` -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. +`prebuild.sh` creates the API-key and test-wallet parameter stubs needed to +compile. On Windows you can also use +`powershell -ExecutionPolicy Bypass -File scripts/prebuild.ps1`. -#### Linux -Run the following commands or launch via Android Studio: -``` +All four configuration arguments are required: + +```sh +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 flutter pub get -flutter run linux +dart tool/check_native_overrides.dart stack_wallet +flutter build linux --release ``` -#### Linux release bundle preparation +Use the appropriate version/build number for your checkout and read the +script's confirmation before proceeding. To use verified dnix prebuilts, +add `-d` to the configuration command. For example: + +```sh +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -d +``` + +| Option | Meaning | +|---|---| +| `-a` | `stack_wallet`, `stack_duo`, or `campfire` | +| `-p` | `android`, `ios`, `macos`, `linux`, or `windows` | +| `-v`, `-b` | App version and numeric build number | +| `-d` | Verified dnix prebuilts; default is verified source builds | +| `-i` | Skip the script's secure-storage and secp256k1 builds; native-assets hooks still run | +| `-f` | Use the existing optional Isar source-build helper; requires its cached source and Rust | +| `-w` | Set the dnix WSL distribution; overrides `DNIX_WSL_DISTRIBUTION` | +| `-h` | Show help | + +Choose `-p` for your target and then run the corresponding Flutter build: + +| Target | Build command | +|---|---| +| Android | `flutter build apk --release` | +| Linux | `flutter build linux --release` | +| macOS | `flutter build macos --release` | +| iOS | `flutter build ios --release` | +| Windows | `flutter build windows --release` | + +Android release builds need `android/key.properties` pointing at your keystore, +with `keyAlias`, `keyPassword`, `storeFile` and `storePassword`. + +Configuration checks Rustup, source-mode Nix/WSL/Xcode requirements, the +Windows secp256k1 tools, and the WSL tools for Stack Wallet's Windows MWEB build +before replacing generated files. A dnix requirement +failure explains how to use `-d` instead; `-d` still requires Rustup for DNSSEC. +The icon/splash `dart run` commands use only those executables' dependency +closures and do **not** run the app's native hooks. App hooks run with later +`flutter run`, `flutter test`, and `flutter build` commands, using the selected +mode until you regenerate the configuration. + +For development, use `flutter devices` and then `flutter run -d `. +The Linux setup builds secp256k1 unless `-i` was supplied. If it was skipped, +run `(cd scripts/linux && bash build_secp256k1.sh)` before starting the app. +On macOS, application builds compile and bundle secp256k1 through the Coinlib +Flutter plugin. Only host-side Dart/Flutter tests need the separate library +from `dart run coinlib:build_macos` after `flutter pub get`. + +### Linux release bundle preparation After `flutter build linux --release`, run this from the repository root before archiving the bundle or using it to build an AppImage or Flatpak: @@ -200,9 +389,8 @@ python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle The helper clears the executable-stack flag on the bundled Monero, Wownero and Salvium wallet libraries, which modern Linux loaders can otherwise reject. It then checks every ELF in the bundle and fails if any still requests an executable -stack. Other libraries are not patched automatically. Ubuntu 24.04's `patchelf` -supports the required `--clear-execstack` option. CI runs this before producing -the Linux tarball, so both downstream packagers inherit the prepared bundle. +stack. Other libraries are not patched automatically. CI runs this before +producing the Linux tarball, so downstream packagers inherit the prepared bundle. Standalone regression tests (also require a C compiler): @@ -210,185 +398,28 @@ Standalone regression tests (also require a C compiler): python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v ``` -## Mac host - -### Dependencies -XCode, Homebrew and several homebrew packages, Rust, and Flutter are required for Mac development with the Flutter SDK. Multiple IDEs may work, but Android Studio is recommended. - -Download and install Xcode at https://developer.apple.com/xcode/, register your device (Mac or iPhone), and enable developer mode for your device as applicable. After installing XCode, make sure commandline tools are installed with `xcode-select --install`. - -Download and install [Homebrew](https://brew.sh/). The following command can install it via script: -``` -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` - -After installing Homebrew, install the following packages: -``` -brew install autoconf automake boost berkeley-db ca-certificates cbindgen cmake cocoapods curl git libssh2 libsodium make openssl@1.1 openssl@3 perl pkg-config rustup-init unbound unzip xz zmq -``` - -The following brew formula *may* be needed: -``` -brew install brotli cairo coreutils gdbm gettext glib gmp libevent libidn2 libnghttp2 libtool libunistring libx11 libxau libxcb libxdmcp libxext libxrender lzo m4 openldap pcre2 pixman procs rtmpdump tcl-tk xorgproto zstd -``` - - -Download and install [Rust](https://www.rust-lang.org/tools/install). [Rustup](https://rustup.rs/) is recommended for Rust setup. Use `rustc` to confirm successful installation. Install toolchain 1.99.1 as well as `cbindgen` and `cargo-lipo` too. You will also have to add the platform target(s) `aarch64-apple-ios` and/or `aarch64-apple-darwin`. You can use the command(s): -``` -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -source ~/.bashrc -rustup install 1.99.1 -rustup default 1.99.1 -cargo install cargo-ndk -cargo install cbindgen cargo-lipo -rustup target add aarch64-apple-ios aarch64-apple-darwin -``` - -Optionally download [Android Studio](https://developer.android.com/studio) as an IDE and activate its Dart and Flutter plugins. VS Code may work as an alternative, but this is not recommended. - -### Flutter -Install Flutter 3.47.2 on your Mac host by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in a terminal to confirm its installation. - -### Build plugins and configure -#### Building plugins for iOS -``` -cd scripts -./build_app.sh -a stack_wallet -p ios -``` - -#### Building plugins for macOS -``` -cd scripts -./build_app.sh -a stack_wallet -p macos -``` - -### Run prebuild script -Certain test wallet parameter and API key template files must be created in order to run Stack Wallet. These can be created by script as in -``` -cd scripts -./prebuild.sh -// when finished go back to the root directory -cd .. -``` -or manually by creating the files referenced in that script with the specified content. - -### Running -#### iOS -Plug in your iOS device or use an emulato and then run the following commands: -``` -flutter pub get -flutter run ios -``` - -#### macOS -Run the following commands or launch via Android Studio: -``` -flutter pub get -flutter run macos -``` - -## Windows host - -### Visual Studio -Visual Studio 2022 is required for Windows development with the Flutter SDK. Download it at https://learn.microsoft.com/en-us/visualstudio/releases/2022/release-history and install the "Desktop development with C++", "Linux development with C++", and "Visual C++ build tools" workloads. You may also need the Windows 10, 11, and/or Universal SDK workloads depending on your Windows version. - -### Build plugins in WSL2 -Set up Ubuntu 24.04 in WSL2. Follow the entire Linux host section in the WSL2 Ubuntu 24.04 host to get set up to build. The Android Studio section may be skipped in WSL (it's only needed on the Windows host). - -Install the following libraries: -``` -sudo apt-get install libgtk2.0-dev nasm mingw-w64 -``` - -The WSL2 host may optionally be navigated to the `stack_wallet` repository on the Windows host in order to build the plugins in-place and skip the next section in which you copy the `dll`s from WSL2 to Windows. - -In this case, you need to enable "metadata" in your wsl setup to be able to modify files on your Windows filesystem. -Add this content to your /etc/wsl.conf in WSL. -``` -[automount] -options = "metadata" -``` -Then restart the wsl from Windows -``` -wsl --shutdown -wsl -``` - -https://stackoverflow.com/questions/46610256/chmod-wsl-bash-doesnt-work/50856772#50856772 - -Then build `secp256k1.dll` by running the following script on the WSL2 Ubuntu 24.04 host: +## CI and configuration checks -- `stack_wallet/scripts/windows/build_secp256k1_wsl.sh` +Application CI uses `-d` for all three apps, so it verifies the four packages' +prebuilts without rebuilding their Nix recipes or installing Nix/WSL. Native +source-build and reproducibility checks belong to the packages' own CI. +Every app still builds DNSSEC with Rust 1.90.0. Xelis, Spark, and other non-dnix +components keep their existing build paths; CI retains Rustup for all apps. -The crypto plugin libraries (epiccash, mwc, frostdart) are native-assets packages and are built by their build hooks during `flutter build`; they no longer need to be built here. +After configuring an app and running `flutter pub get`, check template/mode +selection without compiling native libraries: -If the DLL was built on the WSL filesystem instead of on Windows, copy `stack_wallet/build/secp256k1.dll` to the same position on the Windows host. - -Frostdart will be built by the Windows host later. - -### Install Flutter on Windows host -Install Flutter 3.47.2 on your Windows host (not in WSL2) by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in PowerShell to confirm its installation. - -### Rust -Install [Rust](https://www.rust-lang.org/tools/install) on the Windows host (not in WSL2). Download the installer from [rustup.rs](https://rustup.rs), make sure it works on the commandline (you may need to open a new terminal), and install the following versions: -``` -rustup install 1.99.1 -rustup default 1.99.1 -cargo install cargo-ndk -``` - -### Windows SDK and Developer Mode -Install the Windows SDK: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ You may need to install the [Windows 10 SDK](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive/), which can be installed [by Visual Studio](https://stackoverflow.com/a/73923899) (`Tools > Get Tools and Features... > Modify > Individual Components > Windows 10 SDK`). - -Enable Developer Mode for symlink support, -``` -start ms-settings:developers -``` - -Or enable it automatically from powershell: -``` -PS C:\WINDOWS\system32> reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" -``` - - -Install NuGet: -``` -winget install 9WZDNCRDMDM3 --accept-package-agreements # NuGet, can also use Microsoft.NuGet -``` - -Then restart your terminal and add a source to nuget: -``` -nuget sources add -Name "nuget.org" -Source "https://api.nuget.org/v3/index.json" -``` - -Install and CppWinRT / C++/WinRT SDKs version `2.0.210806.1` with the help of nuget: -``` -nuget install Microsoft.Windows.CppWinRT --Version 2.0.210806.1 -``` - -or [download the package](https://www.nuget.org/packages/Microsoft.Windows.CppWinRT/2.0.210806.1) and [manually install it](https://github.com/Baseflow/flutter-permission-handler/issues/1025#issuecomment-1518576722) by placing it in `flutter/bin` with [nuget.exe](https://dist.nuget.org/win-x86-commandline/latest/nuget.exe) and installing by running `nuget install Microsoft.Windows.CppWinRT -Version 2.0.210806.1` in the root `stack_wallet` folder. - - -### Run prebuild script - -Certain test wallet parameter and API key template files must be created in order to run Stack Wallet on Windows. These can be created by script using PowerShell on the Windows host as in -``` -cd scripts -powershell -ExecutionPolicy Bypass -File prebuild.ps1 -cd .. // When finished go back to the root directory. +```sh +dart scripts/tests/native_build_config_test.dart +dart scripts/tests/build_input_test.dart ``` - -or manually by creating the files referenced in that script with the specified content. - -### Running - -Run the following commands: -``` -flutter pub get -dart run coinlib:build_windows -flutter run -d windows -``` +Configuration tests exercise all three apps, both modes, persistent WSL settings, +Linux builds, and Windows output locations using stubbed native tools. Separate +tests cover prerequisite failures, Xcode setup, Windows tool discovery, and the +secp256k1 build helper. A small Windows CI job runs the PowerShell prebuild regression +tests. Actual native Windows to WSL builds are tested in dnix's CI; these app +tests do not run WSL. ## Xelis tests diff --git a/pubspec.lock b/pubspec.lock index 7046e5df78..e4b66315c0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -836,6 +836,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + dnix: + dependency: transitive + description: + path: "." + ref: "2fd2ed73b9037fe11436f2cde832d7e28d672fa9" + resolved-ref: "2fd2ed73b9037fe11436f2cde832d7e28d672fa9" + url: "https://github.com/cypherstack/dnix.git" + source: git + version: "0.1.0" dnssec_resolver: dependency: transitive description: @@ -1028,8 +1037,8 @@ packages: dependency: "direct main" description: path: "." - ref: d5cfb763fa62928a6e6265e1f4412738772151eb - resolved-ref: d5cfb763fa62928a6e6265e1f4412738772151eb + ref: "63e93b01c3c4c68a52b26a43e4442a4738d5e7e8" + resolved-ref: "63e93b01c3c4c68a52b26a43e4442a4738d5e7e8" url: "https://github.com/cypherstack/flutter_libepiccash.git" source: git version: "0.0.1" @@ -1037,8 +1046,8 @@ packages: dependency: "direct main" description: path: "." - ref: d8c5d5488cb00e21d0dc04ad306a65e2d54c4d3c - resolved-ref: d8c5d5488cb00e21d0dc04ad306a65e2d54c4d3c + ref: "5722fb3ba05c8f52e8e0255c485ae5ea52e9a694" + resolved-ref: "5722fb3ba05c8f52e8e0255c485ae5ea52e9a694" url: "https://github.com/cypherstack/flutter_libmwc.git" source: git version: "0.1.0" @@ -1241,8 +1250,8 @@ packages: dependency: "direct main" description: path: "." - ref: c8be6a0ea892d744adfacc8e97df891feca49707 - resolved-ref: c8be6a0ea892d744adfacc8e97df891feca49707 + ref: f7fea2a926ea88e2eb6a353579372188d6915314 + resolved-ref: f7fea2a926ea88e2eb6a353579372188d6915314 url: "https://github.com/cypherstack/frostdart.git" source: git version: "0.3.0" @@ -1654,8 +1663,8 @@ packages: dependency: "direct main" description: path: "." - ref: 21f95309522dd48face32c0c4a3ca2ac8df092fb - resolved-ref: 21f95309522dd48face32c0c4a3ca2ac8df092fb + ref: "21f95309522dd48face32c0c4a3ca2ac8df092fb" + resolved-ref: "21f95309522dd48face32c0c4a3ca2ac8df092fb" url: "https://github.com/cypherstack/mobile_app_privacy" source: git version: "0.0.5" @@ -2409,8 +2418,8 @@ packages: dependency: "direct main" description: path: "." - ref: e30ed0d386a40ee4a999aeaf57aafd32921d601e - resolved-ref: e30ed0d386a40ee4a999aeaf57aafd32921d601e + ref: "6e954a9d5a0aaaafb861049b79041d7ae98c2a08" + resolved-ref: "6e954a9d5a0aaaafb861049b79041d7ae98c2a08" url: "https://github.com/cypherstack/tor.git" source: git version: "0.2.0" @@ -2722,7 +2731,7 @@ packages: source: hosted version: "1.2.0" yaml: - dependency: transitive + dependency: "direct dev" description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce diff --git a/scripts/app_config/shared/asset_generators.sh b/scripts/app_config/shared/asset_generators.sh index a2d9487585..626ce32622 100755 --- a/scripts/app_config/shared/asset_generators.sh +++ b/scripts/app_config/shared/asset_generators.sh @@ -12,22 +12,10 @@ APP_BUILD_PLATFORM=$1 # run icon and image generators pushd "${APP_PROJECT_ROOT_DIR}" YAML_FILE="${APP_PROJECT_ROOT_DIR}/scripts/app_config/platforms/${APP_BUILD_PLATFORM}/flutter_launcher_icons.yaml" -if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then - cmd.exe /c flutter pub get - if command -v cygpath >/dev/null 2>&1; then - WIN_PATH_VERSION=$(cygpath -w "${YAML_FILE}") - else - WIN_PATH_VERSION=$(wslpath -w "${YAML_FILE}") - fi - cmd.exe /c dart run flutter_launcher_icons -f "${WIN_PATH_VERSION}" - # not needed in windows -# cmd.exe /c dart run flutter_native_splash:create -else - flutter pub get - dart run flutter_launcher_icons -f "${YAML_FILE}" +flutter pub get +dart run flutter_launcher_icons -f "${YAML_FILE}" - if [[ "${APP_BUILD_PLATFORM}" = 'ios' || "${APP_BUILD_PLATFORM}" = 'android' ]]; then - dart run flutter_native_splash:create - fi +if [[ "${APP_BUILD_PLATFORM}" = 'ios' || "${APP_BUILD_PLATFORM}" = 'android' ]]; then + dart run flutter_native_splash:create fi -popd \ No newline at end of file +popd diff --git a/scripts/app_config/shared/link_assets.sh b/scripts/app_config/shared/link_assets.sh index 9c7147e3ed..7dec8ed960 100755 --- a/scripts/app_config/shared/link_assets.sh +++ b/scripts/app_config/shared/link_assets.sh @@ -23,14 +23,11 @@ for dirname in "default_themes" "icon" "lottie" "in_app_logo_icons" "svg"; do rm -f "${ASSETS_DIR}/${dirname}" if [[ "${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}") - else - LINK_SOURCE_DIR_WIN_PATH_VERSION=$(wslpath -w "${LINK_SOURCE_DIR}") - LINK_NAME_WIN_PATH_VERSION=$(wslpath -w "${ASSETS_DIR}") - fi - cmd.exe /c mklink /D "${LINK_NAME_WIN_PATH_VERSION}\\${dirname}" "${LINK_SOURCE_DIR_WIN_PATH_VERSION}" + LINK_SOURCE_DIR_WIN_PATH_VERSION=$(cygpath -w "${LINK_SOURCE_DIR}") + LINK_NAME_WIN_PATH_VERSION=$(cygpath -w "${ASSETS_DIR}") + # Keep CMD switches out of Git Bash's path conversion; paths are native above. + MSYS2_ARG_CONV_EXCL="/C;/D${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ + cmd.exe /C mklink /D "${LINK_NAME_WIN_PATH_VERSION}\\${dirname}" "${LINK_SOURCE_DIR_WIN_PATH_VERSION}" else ln -s "${LINK_SOURCE_DIR}" "${ASSETS_DIR}/${dirname}" fi diff --git a/scripts/app_config/templates/isar_build.sh b/scripts/app_config/templates/isar_build.sh index d1d53d7f93..4b78990365 100644 --- a/scripts/app_config/templates/isar_build.sh +++ b/scripts/app_config/templates/isar_build.sh @@ -45,7 +45,9 @@ build_isar_core() { return 0 fi - (cd "${isar_core_path}" && cargo build --release) || { + # Use the toolchain formerly supplied by the Docker image, without changing + # the developer's default. Rustup installs it only when it is missing. + (cd "${isar_core_path}" && rustup run --install 1.99.1 cargo build --release) || { echo "Error: cargo build failed for isar_core_ffi" >&2 return 1 } diff --git a/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj b/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj index d5b9e5fc04..254347f276 100644 --- a/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj +++ b/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -646,7 +646,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -694,7 +694,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 5c1a1907a7..3761771c1e 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -29,7 +29,7 @@ dependencies: # frostdart: # git: # url: https://github.com/cypherstack/frostdart.git -# ref: c8be6a0ea892d744adfacc8e97df891feca49707 +# ref: f7fea2a926ea88e2eb6a353579372188d6915314 # %%END_ENABLE_FROST%% # %%ENABLE_XEL%% @@ -54,21 +54,21 @@ dependencies: # flutter_libepiccash: # git: # url: https://github.com/cypherstack/flutter_libepiccash.git -# ref: d5cfb763fa62928a6e6265e1f4412738772151eb +# ref: 63e93b01c3c4c68a52b26a43e4442a4738d5e7e8 # %%END_ENABLE_EPIC%% # %%ENABLE_MWC%% # flutter_libmwc: # git: # url: https://github.com/cypherstack/flutter_libmwc.git -# ref: d8c5d5488cb00e21d0dc04ad306a65e2d54c4d3c +# ref: 5722fb3ba05c8f52e8e0255c485ae5ea52e9a694 # %%END_ENABLE_MWC%% # %%ENABLE_TOR%% # tor_ffi_plugin: # git: # url: https://github.com/cypherstack/tor.git -# ref: e30ed0d386a40ee4a999aeaf57aafd32921d601e +# ref: 6e954a9d5a0aaaafb861049b79041d7ae98c2a08 # %%END_ENABLE_TOR%% # %%ENABLE_XMR%% @@ -280,6 +280,7 @@ dependencies: dev_dependencies: args: ^2.7.0 test: ^1.31.1 + yaml: ^3.1.3 flutter_test: sdk: flutter integration_test: @@ -365,28 +366,23 @@ dependency_overrides: # ref: 39ea19ff035518aef4d0e776206d96a428f57789 # %%END_ENABLE_ISAR%% -# Prebuilt native assets for CI. Enabled by scripts/build_app.sh -d. -# Each listed plugin's build hook downloads and verifies prebuilt binaries -# instead of compiling its Rust sources. Entries for packages that are not in -# the dependency graph are ignored, so this block is safe for every app config. -# Each url/sha256 pair is tied to a source fingerprint of the plugin, so it must -# be updated together with the plugin git ref pinned in dependencies above. -# %%ENABLE_NATIVE_PREBUILTS%% -#hooks: -# user_defines: -# tor_ffi_plugin: -# native_build: prebuilt -# prebuilt_manifest_url: "https://github.com/cypherstack/tor/releases/download/native-0.2.0/manifest.json" -# prebuilt_manifest_sha256: "0008ecf60b52a5ba1ffecb2f5951a5d5c4bbffdb71ab470259d62d4abc314c4b" -# flutter_libepiccash: -# native_build: prebuilt -# prebuilt_manifest_url: "https://github.com/cypherstack/flutter_libepiccash/releases/download/native-0.3.0-1/manifest.json" -# prebuilt_manifest_sha256: "ab91bef838c8e658c5167e3ff6599eec1e0db24223de20394658a277913aec38" -# flutter_libmwc: -# native_build: prebuilt -# prebuilt_manifest_url: "https://github.com/cypherstack/flutter_libmwc/releases/download/native-0.1.7/manifest.json" -# prebuilt_manifest_sha256: "c83b8d57c4bde1f88d580587f8df91480bb083a95e58bbf0f0a3c47855e6522d" -# %%END_ENABLE_NATIVE_PREBUILTS%% +# dnix verifies both source builds and prebuilts against each package's dnix.lock. +# build_app.sh renders the mode as source by default, or prebuilt with -d. +# Settings for packages absent from an app's dependency graph are ignored. +hooks: + user_defines: + frostdart: + build: PLACEHOLDER_DNIX_BUILD_MODE + wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION + tor_ffi_plugin: + build: PLACEHOLDER_DNIX_BUILD_MODE + wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION + flutter_libepiccash: + build: PLACEHOLDER_DNIX_BUILD_MODE + wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION + flutter_libmwc: + build: PLACEHOLDER_DNIX_BUILD_MODE + wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/scripts/build_app.sh b/scripts/build_app.sh index db0f94000c..253cc58435 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -6,8 +6,13 @@ source "$(dirname -- "${BASH_SOURCE[0]}")/env.sh" # Function to display usage. usage() { - echo "Usage: $0 -v -b -p -a [-d] [-i] [-f] [-s]" - exit 1 + echo "Usage: $0 -v -b -p -a [-d] [-i] [-f] [-w ]" + echo " Default: build dnix libraries from source with Nix and verify their hashes." + echo " -d: use verified dnix prebuilts; other packages may still build from source." + echo " -i: skip platform native dependencies (secure storage and secp256k1)." + echo " -f: build Isar from source." + echo " -w: dnix WSL distribution (default: DNIX_WSL_DISTRIBUTION or WSL default)." + exit "${1:-1}" } confirmDisclaimer() { @@ -31,24 +36,26 @@ 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 +# dnix checks the package's pinned hashes in both source and prebuilt mode. +DNIX_BUILD_MODE=source +DNIX_WSL_DISTRIBUTION="${DNIX_WSL_DISTRIBUTION:-}" BUILD_ISAR_FROM_SOURCE=0 USE_SYSTEM_SECURE_STORAGE_DEPS=0 # Parse command-line arguments. -while getopts "v:b:p:a:idfs" opt; do +while getopts "v:b:p:a:w:idfsh" opt; do case "${opt}" in v) APP_VERSION_STRING="$OPTARG" ;; b) APP_BUILD_NUMBER="$OPTARG" ;; p) APP_BUILD_PLATFORM="$OPTARG" ;; a) APP_NAMED_ID="$OPTARG" ;; i) SKIP_NATIVE_DEPS_BUILD=1 ;; - d) DOWNLOAD_CRYPTO_PLUGINS=1 ;; + d) DNIX_BUILD_MODE=prebuilt ;; f) BUILD_ISAR_FROM_SOURCE=1 ;; s) USE_SYSTEM_SECURE_STORAGE_DEPS=1 ;; + w) [ -n "$OPTARG" ] || { echo "Invalid WSL distribution: -w needs a name"; usage; } + DNIX_WSL_DISTRIBUTION="$OPTARG" ;; + h) usage 0 ;; *) usage ;; esac done @@ -94,6 +101,25 @@ if [[ ! "$APP_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then fi confirmDisclaimer + +# Check requirements before replacing any generated app/platform files. +# Windows apps are configured from Git Bash and built by native Windows Flutter. +if [ "$APP_BUILD_PLATFORM" = windows ]; then + case "$(uname -s)" in + MINGW*|MSYS*) ;; + *) echo "Configure Windows builds from Git Bash on Windows." >&2; exit 1 ;; + esac +fi +dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" +DNIX_DISTRIBUTION_ARGS=() +if [ -n "$DNIX_WSL_DISTRIBUTION" ]; then + DNIX_DISTRIBUTION_ARGS=("$DNIX_WSL_DISTRIBUTION") +fi +dart "${APP_PROJECT_ROOT_DIR}/tool/check_build_environment.dart" \ + "$APP_BUILD_PLATFORM" "$APP_NAMED_ID" "$DNIX_BUILD_MODE" "${DNIX_DISTRIBUTION_ARGS[@]}" +if [ "$APP_BUILD_PLATFORM" = windows ] && [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then + dart "${APP_PROJECT_ROOT_DIR}/scripts/windows/build_secp256k1.dart" --check +fi set -x source "${APP_PROJECT_ROOT_DIR}/scripts/app_config/templates/configure_template_files.sh" @@ -112,20 +138,25 @@ cp -rp "${APP_PROJECT_ROOT_DIR}/asset_sources/other/playstore_icon/${APP_NAMED_I "${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 - # 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 -fi +# Record settings for later app builds. Icon/splash executables do not run the +# application's native hooks; each executable uses its own dependency closure. +dart "${APP_PROJECT_ROOT_DIR}/tool/configure_native_assets.dart" \ + "${ACTUAL_PUBSPEC}" "$DNIX_BUILD_MODE" "${DNIX_DISTRIBUTION_ARGS[@]}" "${APP_PROJECT_ROOT_DIR}/scripts/app_config/platforms/${APP_BUILD_PLATFORM}/platform_config.sh" if [[ "$APP_BUILD_PLATFORM" != "linux" ]]; then # run icon and image generators after project config has completed for non linux "${APP_PROJECT_ROOT_DIR}/scripts/app_config/shared/asset_generators.sh" "${APP_BUILD_PLATFORM}" +else + # Linux skips asset generation; resolve here so the check below is current. + (cd "${APP_PROJECT_ROOT_DIR}" && flutter pub get) fi +# Inspect the checkouts that the configured dependencies resolve to. +dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" + # Build platform native deps that are not provided by pub packages. The crypto -# plugins (flutter_libepiccash, flutter_libmwc, frostdart) are native-assets +# plugins (flutter_libepiccash, flutter_libmwc, frostdart, tor_ffi_plugin) are native-assets # packages now; their build hooks compile (or fetch) them during flutter build. if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then case "$APP_BUILD_PLATFORM" in @@ -136,12 +167,9 @@ if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then popd ;; windows) - # WSL cross-compile of secp256k1.dll. CI builds it natively instead. - if [ "$DOWNLOAD_CRYPTO_PLUGINS" -eq 0 ]; then - pushd "${APP_PROJECT_ROOT_DIR}/scripts/windows" - ./build_secp256k1_wsl.sh - popd - fi + # -d only selects dnix prebuilts. Rebuild this separate library + # unless -i skips platform native dependencies. + dart "${APP_PROJECT_ROOT_DIR}/scripts/windows/build_secp256k1.dart" ;; esac fi diff --git a/scripts/prebuild.ps1 b/scripts/prebuild.ps1 index 8b777edd22..383515c79a 100644 --- a/scripts/prebuild.ps1 +++ b/scripts/prebuild.ps1 @@ -1,6 +1,10 @@ +# Resolve paths from this script, regardless of the caller's working directory. +$ErrorActionPreference = 'Stop' +$PROJECT_ROOT = Split-Path -Parent $PSScriptRoot + # Create template lib/external_api_keys.dart file if it doesn't already exist -$KEYS = "..\lib\external_api_keys.dart" -if (-not (Test-Path $KEYS)) { +$KEYS = Join-Path $PROJECT_ROOT 'lib/external_api_keys.dart' +if (-not (Test-Path -LiteralPath $KEYS -PathType Leaf)) { Write-Host "prebuild.ps1: creating template lib/external_api_keys.dart file" @' @@ -19,7 +23,7 @@ const kCypherGoatApiKey = ""; const kCypherGoatAffiliate = ""; const kTrocadorApiKey = ""; const kTrocadorRefCode = ""; -'@ | Out-File $KEYS -Encoding UTF8 +'@ | Out-File -LiteralPath $KEYS -Encoding UTF8 } @@ -27,9 +31,13 @@ const kTrocadorRefCode = ""; $coins = @("bitcoin", "bitcoincash", "dogecoin", "namecoin", "firo", "particl") # TODO add monero and wownero when those tests are updated to use the .gitignored test wallet setup: when doing that, make sure to update the test vectors for a new, private development seed foreach ($coin in $coins) { - $WALLETTESTPARAMFILE = "..\test\services\coins\$coin\${coin}_wallet_test_parameters.dart" - if (-not (Test-Path $WALLETTESTPARAMFILE)) { + $WALLETTESTPARAMFILE = Join-Path $PROJECT_ROOT "test/services/coins/$coin/${coin}_wallet_test_parameters.dart" + if (-not (Test-Path -LiteralPath $WALLETTESTPARAMFILE -PathType Leaf)) { Write-Host "prebuild.ps1: creating template test/services/coins/$coin/${coin}_wallet_test_parameters.dart file" - "const TEST_MNEMONIC = "";" + "`nconst ROOT_WIF = "";" + "`nconst NODE_WIF_84 = "";" | Out-File -FilePath $WALLETTESTPARAMFILE -Encoding UTF8 + @' +const TEST_MNEMONIC = ""; +const ROOT_WIF = ""; +const NODE_WIF_84 = ""; +'@ | Out-File -LiteralPath $WALLETTESTPARAMFILE -Encoding UTF8 } } diff --git a/scripts/tests/build_environment_test.dart b/scripts/tests/build_environment_test.dart new file mode 100644 index 0000000000..d8406332a1 --- /dev/null +++ b/scripts/tests/build_environment_test.dart @@ -0,0 +1,440 @@ +// Run with: dart scripts/tests/build_environment_test.dart +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../tool/check_build_environment.dart'; +import '../../tool/native_dependency_contract.dart'; + +const fixtureXcode = XcodeRequirement( + version: '27.0', + build: '27A266a', + upstreamBuildPinned: false, +); + +class HostFixture { + String os = 'macos'; + String architecture = 'arm64'; + bool rustupOnPath = true; + bool rustupInHome = false; + bool nix = true; + bool mwebTools = true; + int wslExit = 0; + final extraVariables = {}; + String selected = '/Applications/Xcode.app/Contents/Developer'; + final applications = ['/Applications/Xcode.app']; + final versions = { + '/Applications/Xcode.app/Contents/Developer': + 'Xcode ${fixtureXcode.version}\nBuild version ${fixtureXcode.build}\n', + }; + final calls = + < + ({String executable, List arguments, Map env}) + >[]; + + BuildEnvironment get host => BuildEnvironment( + os: os, + architecture: architecture, + variables: { + 'HOME': '/Users/test', + 'USERPROFILE': r'C:\Users\test', + 'DEVELOPER_DIR': '/Applications/Caller-selected.app/Contents/Developer', + ...extraVariables, + }, + listXcodes: () async => applications.toList(), + run: (executable, arguments, environment) async { + calls.add(( + executable: executable, + arguments: arguments.toList(), + env: Map.of(environment), + )); + if (executable == 'rustup') { + if (!rustupOnPath) throw ProcessException(executable, arguments); + return ProcessResult(0, 0, 'rustup fixture', ''); + } + if (executable.endsWith('rustup')) { + if (!rustupInHome) throw ProcessException(executable, arguments); + return ProcessResult(0, 0, 'rustup fixture', ''); + } + if (executable == 'nix') { + if (!nix) throw ProcessException(executable, arguments); + return ProcessResult(0, 0, 'nix fixture', ''); + } + if (executable == 'wsl.exe') { + if (arguments.contains('bash')) { + return ProcessResult(0, mwebTools ? 0 : 1, '', ''); + } + return ProcessResult(0, wslExit, 'nix fixture', ''); + } + if (executable == '/usr/bin/xcode-select') { + return ProcessResult(0, selected.isEmpty ? 1 : 0, selected, ''); + } + if (executable == '/usr/bin/xcodebuild') { + final version = versions[environment['DEVELOPER_DIR']]; + return ProcessResult(0, version == null ? 1 : 0, version ?? '', ''); + } + throw StateError('Unexpected subprocess: $executable $arguments'); + }, + ); + + Future> check({ + String mode = 'source', + String platform = 'macos', + String app = 'stack_wallet', + String? distribution, + XcodeRequirement requirement = fixtureXcode, + }) => checkBuildEnvironment( + host: host, + platform: platform, + app: app, + mode: mode, + wslDistribution: distribution, + xcodeRequirement: mode == 'source' && {'ios', 'macos'}.contains(platform) + ? requirement + : null, + ); +} + +void main() { + late HostFixture fixture; + setUp(() => fixture = HostFixture()); + + for (final app in ['stack_wallet', 'stack_duo', 'campfire']) { + test('$app needs Rustup even with verified prebuilts', () async { + fixture.rustupOnPath = false; + final errors = await fixture.check(app: app, mode: 'prebuilt'); + expect(errors.single, contains('Every app needs Rust 1.90.0')); + expect(errors.single, contains('including with -d')); + expect( + fixture.calls.every((call) => call.executable.endsWith('rustup')), + isTrue, + ); + }); + } + + test('prebuilt mode skips dnix tools on an Intel Mac', () async { + fixture.architecture = 'x64'; + fixture.nix = false; + fixture.applications.clear(); + expect(await fixture.check(mode: 'prebuilt'), isEmpty); + expect(fixture.calls.single.executable, 'rustup'); + }); + + test( + 'finds Rustup in the normal home fallback without installing tools', + () async { + fixture.rustupOnPath = false; + fixture.rustupInHome = true; + expect(await fixture.check(mode: 'prebuilt'), isEmpty); + expect(fixture.calls.last.executable, '/Users/test/.cargo/bin/rustup'); + expect( + fixture.calls.every((call) => call.arguments.join() == '--version'), + isTrue, + ); + }, + ); + + test('missing Nix fails early with the application -d alternative', () async { + fixture.nix = false; + final errors = await fixture.check(); + expect(errors.single, contains('Nix on PATH')); + expect(errors.single, contains('build_app.sh with -d')); + }); + + test('source mode rejects Intel Macs with an actionable message', () async { + fixture.architecture = 'x64'; + expect((await fixture.check()).single, contains('Apple Silicon')); + expect( + fixture.calls.any((call) => call.executable.contains('xcode')), + isFalse, + ); + }); + + test('ARM Linux source mode cannot use the Android NDK', () async { + fixture.os = 'linux'; + expect( + (await fixture.check(platform: 'android')).single, + contains('x86_64 Linux'), + ); + expect(await fixture.check(platform: 'linux'), isEmpty); + }); + + test( + 'WSL distribution is a separate argument; probe only checks Nix', + () async { + fixture.os = 'windows'; + const distribution = 'Ubuntu dev; echo "fixture"'; + expect( + await fixture.check(platform: 'windows', distribution: distribution), + isEmpty, + ); + final call = fixture.calls.last; + expect(call.executable, 'wsl.exe'); + expect(call.arguments.take(2), ['--distribution', distribution]); + expect( + call.arguments, + containsAllInOrder(['--cd', '/', '--exec', '/bin/sh', '-c']), + ); + expect(call.arguments, contains(wslNixProbe)); + expect(call.arguments.last, 'windows'); + expect(call.env['WSL_UTF8'], '1'); + expect(wslNixProbe, contains(r'"$HOME/.nix-profile/bin"')); + expect(wslNixProbe, contains('/nix/var/nix/profiles/default/bin')); + expect(wslNixProbe, contains('/run/current-system/sw/bin')); + expect(wslNixProbe, contains('nix --version')); + }, + ); + + test( + 'failed WSL probe identifies the distribution and prebuilt alternative', + () async { + fixture.os = 'windows'; + fixture.wslExit = 127; + final errors = await fixture.check( + platform: 'windows', + distribution: 'Ubuntu-24.04', + ); + expect(errors.single, contains('distribution "Ubuntu-24.04"')); + expect(errors.single, contains('with -d')); + }, + ); + + test('Android cannot build from source in an ARM WSL distribution', () async { + fixture.os = 'windows'; + fixture.wslExit = 78; + expect( + (await fixture.check(platform: 'android')).single, + contains('x86_64 WSL distribution'), + ); + }); + + for (final os in ['linux', 'windows']) { + test('Apple targets on a $os host are rejected in both modes', () async { + fixture.os = os; + for (final platform in ['ios', 'macos']) { + for (final mode in ['source', 'prebuilt']) { + expect(await fixture.check(platform: platform, mode: mode), [ + 'Apple targets require a macOS build host.', + ]); + } + } + expect(fixture.calls, isEmpty); + }); + } + + test( + 'only Apple source builds on Apple Silicon need Xcode metadata', + () async { + for (final (os, architecture, error) in [ + ('linux', 'x64', 'Apple targets require a macOS build host.'), + ('windows', 'x64', 'Apple targets require a macOS build host.'), + ('macos', 'x64', 'dnix source builds require an Apple Silicon Mac.'), + ]) { + fixture.os = os; + fixture.architecture = architecture; + final errors = await checkBuildEnvironment( + host: fixture.host, + platform: 'ios', + app: 'stack_wallet', + mode: 'source', + ); + expect(errors.first, startsWith(error)); + } + fixture.os = 'macos'; + fixture.architecture = 'arm64'; + expect( + () => checkBuildEnvironment( + host: fixture.host, + platform: 'ios', + app: 'stack_wallet', + mode: 'source', + ), + throwsArgumentError, + ); + }, + ); + + test( + 'Stack Wallet on Windows checks the WSL MWEB tools in both modes', + () async { + fixture.os = 'windows'; + fixture.mwebTools = false; + for (final mode in ['source', 'prebuilt']) { + final errors = await fixture.check(platform: 'windows', mode: mode); + expect(errors.single, contains('x86_64-w64-mingw32-gcc')); + expect(errors.single, contains('MWEBD_FETCH=1')); + } + final probe = fixture.calls.firstWhere( + (call) => call.arguments.contains('bash'), + ); + expect(probe.executable, 'wsl.exe'); + expect(probe.arguments, isNot(contains('--distribution'))); + expect( + probe.arguments, + containsAllInOrder(['--exec', 'bash', '-l', '-c']), + ); + }, + ); + + test('MWEBD_FETCH, CI and other apps skip the MWEB tools check', () async { + fixture.os = 'windows'; + fixture.mwebTools = false; + expect( + await fixture.check( + platform: 'windows', + app: 'campfire', + mode: 'prebuilt', + ), + isEmpty, + ); + fixture.extraVariables['MWEBD_FETCH'] = '1'; + expect(await fixture.check(platform: 'windows', mode: 'prebuilt'), isEmpty); + fixture.extraVariables + ..remove('MWEBD_FETCH') + ..['CI'] = 'true'; + expect(await fixture.check(platform: 'windows', mode: 'prebuilt'), isEmpty); + expect( + fixture.calls.where((call) => call.arguments.contains('bash')), + isEmpty, + ); + }); + + for (final os in ['linux', 'macos']) { + test('Windows configuration on a $os host is rejected', () async { + fixture.os = os; + for (final mode in ['source', 'prebuilt']) { + expect(await fixture.check(platform: 'windows', mode: mode), [ + 'Configure Windows builds from Git Bash on Windows.', + ]); + } + expect(fixture.calls, isEmpty); + }); + } + + test('global GA Xcode wins over an alphabetically earlier beta', () async { + fixture.applications.add('/Applications/Xcode-27.0-Beta.app'); + fixture.versions['/Applications/Xcode-27.0-Beta.app/Contents/Developer'] = + 'Xcode 27.0\nBuild version 27A111b\n'; + expect(await fixture.check(), isEmpty); + final select = fixture.calls.singleWhere( + (call) => call.executable.endsWith('xcode-select'), + ); + expect(select.env, isNot(contains('DEVELOPER_DIR'))); + final version = fixture.calls.singleWhere( + (call) => call.executable.endsWith('xcodebuild'), + ); + expect(version.env['DEVELOPER_DIR'], fixture.selected); + }); + + test( + 'global beta is rejected even when caller exported GA and GA is installed', + () async { + fixture.selected = '/Applications/Xcode-beta.app/Contents/Developer'; + fixture.versions[fixture.selected] = + 'Xcode 27.0\nBuild version 27A111b\n'; + final errors = await fixture.check(); + expect(errors.single, contains('27A111b')); + expect(errors.single, contains('DEVELOPER_DIR is not forwarded')); + expect(errors.single, contains('Use -d')); + }, + ); + + test( + 'dnix scan chooses the first matching version, not a later GA', + () async { + fixture.selected = '/Library/Developer/CommandLineTools'; + fixture.applications.add('/Applications/Xcode-27.0-Beta.app'); + fixture.versions['/Applications/Xcode-27.0-Beta.app/Contents/Developer'] = + 'Xcode 27.0\nBuild version 27A111b\n'; + final errors = await fixture.check(); + expect(errors.single, contains('/Applications/Xcode-27.0-Beta.app')); + }, + ); + + test('dnix finds GA when the global selection is another version', () async { + fixture.selected = '/Applications/Xcode26.app/Contents/Developer'; + fixture.versions[fixture.selected] = 'Xcode 26.0\nBuild version 26A1\n'; + expect(await fixture.check(), isEmpty); + }); + + test('Xcode version comes from the native dependency requirement', () async { + const futureXcode = XcodeRequirement( + version: '28.0', + build: '28A123', + upstreamBuildPinned: true, + ); + fixture.applications.add('/Applications/Xcode28.app'); + fixture.versions['/Applications/Xcode28.app/Contents/Developer'] = + 'Xcode 28.0\nBuild version 28A123\n'; + expect(await fixture.check(requirement: futureXcode), isEmpty); + expect( + fixture.calls.last.env['DEVELOPER_DIR'], + '/Applications/Xcode28.app/Contents/Developer', + ); + }); + + test( + 'dnix build pin skips a beta and selects the matching release', + () async { + fixture.selected = '/Applications/Xcode-beta.app/Contents/Developer'; + fixture.versions[fixture.selected] = + 'Xcode 27.0\nBuild version 27A111b\n'; + expect( + await fixture.check( + requirement: const XcodeRequirement( + version: '27.0', + build: '27A266a', + upstreamBuildPinned: true, + ), + ), + isEmpty, + ); + expect( + fixture.calls.last.env['DEVELOPER_DIR'], + '/Applications/Xcode.app/Contents/Developer', + ); + }, + ); + + test('Apple source builds require metadata before any subprocess', () async { + await expectLater( + checkBuildEnvironment( + host: fixture.host, + platform: 'macos', + app: 'stack_wallet', + mode: 'source', + ), + throwsArgumentError, + ); + expect(fixture.calls, isEmpty); + }); + + test('missing Xcode explains the setup helper', () async { + fixture.versions.clear(); + expect( + (await fixture.check()).single, + contains('dart tool/setup_xcode.dart'), + ); + }); + + for (final distribution in [ + '', + '-Ubuntu', + 'Ubuntu\nInjected', + 'Ubuntu\x00', + 'Ubuntu\tDev', + 'Ubuntu\x1b', + 'Ubuntu\x7f', + ]) { + test( + 'rejects WSL name before subprocesses: ${distribution.codeUnits}', + () async { + await expectLater( + fixture.check(distribution: distribution), + throwsArgumentError, + ); + expect(fixture.calls, isEmpty); + }, + ); + } +} diff --git a/scripts/tests/build_input_test.dart b/scripts/tests/build_input_test.dart index 5cbbb88d5a..e11131529d 100644 --- a/scripts/tests/build_input_test.dart +++ b/scripts/tests/build_input_test.dart @@ -85,6 +85,7 @@ exit 91 (option: 'a', value: 'unknown', message: 'Invalid app id'), (option: 'v', value: '1.2.3/invalid', message: 'Invalid version'), (option: 'b', value: '12-invalid', message: 'Invalid build number'), + (option: 'w', value: '', message: 'Invalid WSL distribution'), ]) { test('rejects invalid -${invalid.option} before writes ' 'from $location', () async { @@ -120,6 +121,54 @@ exit 91 } }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); + for (final toolchainStatus in [0, 42]) { + test('Isar toolchain invocation handles exit $toolchainStatus', () async { + final workspace = Directory(p.join(root.path, 'isar source')) + ..createSync(); + final core = Directory(p.join(workspace.path, 'isar_core_ffi')) + ..createSync(); + File(p.join(core.path, 'Cargo.toml')).writeAsStringSync('fixture'); + final calls = File(p.join(root.path, 'rustup-call')); + final result = await Process.run( + 'bash', + [ + '-c', + r''' +source "$1" +rustup() { + printf '%s\n' "$PWD" "$@" > "$ISAR_TEST_CALLS" + return "$ISAR_TEST_STATUS" +} +cargo() { return 99; } +build_isar_core "$2" "$3" +''', + 'isar-toolchain-test', + p.join(repository.path, 'scripts/app_config/templates/isar_build.sh'), + core.path, + workspace.path, + ], + environment: { + 'ISAR_TEST_CALLS': calls.path, + 'ISAR_TEST_STATUS': '$toolchainStatus', + }, + ); + expect( + result.exitCode, + toolchainStatus == 0 ? 0 : 1, + reason: '${result.stderr}', + ); + expect(calls.readAsLinesSync(), [ + core.path, + 'run', + '--install', + '1.99.1', + 'cargo', + 'build', + '--release', + ]); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); + } + group('prebuild', () { const coins = [ 'bitcoin', diff --git a/scripts/tests/configure_native_assets_test.dart b/scripts/tests/configure_native_assets_test.dart new file mode 100644 index 0000000000..312d2f6b23 --- /dev/null +++ b/scripts/tests/configure_native_assets_test.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; + +import '../../tool/configure_native_assets.dart'; +import '../../tool/native_build_options.dart'; + +const template = '''hooks: + user_defines: + tor_ffi_plugin: + build: PLACEHOLDER_DNIX_BUILD_MODE + wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION +'''; + +void main() { + test('default distribution omits the setting in both build modes', () { + for (final mode in ['source', 'prebuilt']) { + final rendered = configureNativeAssets(template, mode, null); + expect(rendered, contains('build: $mode\n')); + expect(rendered, isNot(contains('wsl_distribution:'))); + expect(rendered, isNot(contains('PLACEHOLDER'))); + } + }); + + test('distribution is normalized and safely quoted as one YAML value', () { + const name = 'Ubuntu dev: "quoted" \\ # preserved'; + final rendered = configureNativeAssets(template, 'source', ' $name '); + final value = rendered + .split('\n') + .singleWhere((line) => line.contains('wsl_distribution:')) + .split('wsl_distribution: ') + .last; + expect(jsonDecode(value), name); + expect(validatedDistribution(' $name '), name); + }); + + test( + 'prebuilt mode retains a requested distribution for later source use', + () { + final rendered = configureNativeAssets( + template, + 'prebuilt', + 'Ubuntu-24.04', + ); + expect(rendered, contains('build: prebuilt\n')); + expect(rendered, contains('wsl_distribution: "Ubuntu-24.04"\n')); + }, + ); + + for (final invalid in [ + '', + ' ', + '-Ubuntu', + ' -Ubuntu ', + for (final control in [...List.generate(32, (i) => i), 127]) + 'Ubuntu${String.fromCharCode(control)}', + ]) { + test('shared validation rejects invalid name ${invalid.codeUnits}', () { + expect(() => validatedDistribution(invalid), throwsArgumentError); + for (final mode in ['source', 'prebuilt']) { + expect( + () => configureNativeAssets(template, mode, invalid), + throwsArgumentError, + ); + } + }); + } +} diff --git a/scripts/tests/native_build_config_test.dart b/scripts/tests/native_build_config_test.dart new file mode 100644 index 0000000000..ab215d1733 --- /dev/null +++ b/scripts/tests/native_build_config_test.dart @@ -0,0 +1,433 @@ +// Run with: dart scripts/tests/native_build_config_test.dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../../tool/configure_native_assets.dart'; +import '../../tool/native_dependency_contract.dart' show enabledDnixPackages; + +void main() { + final repository = File.fromUri(Platform.script).parent.parent.parent; + const apps = ['stack_wallet', 'stack_duo', 'campfire']; + const allPackages = { + 'frostdart', + 'tor_ffi_plugin', + 'flutter_libepiccash', + 'flutter_libmwc', + }; + late Directory root; + late Directory bin; + + void copyFile(String relative) { + final destination = File(p.join(root.path, relative)); + destination.parent.createSync(recursive: true); + File(p.join(repository.path, relative)).copySync(destination.path); + } + + void copyDirectory(String relative) { + for (final file in Directory( + p.join(repository.path, relative), + ).listSync(recursive: true).whereType()) { + copyFile(p.relative(file.path, from: repository.path)); + } + } + + void script(String relative, String body) { + final file = File(p.join(root.path, relative)); + file.parent.createSync(recursive: true); + file.writeAsStringSync('#!/bin/sh\nset -eu\n$body\n'); + final result = Process.runSync('chmod', ['+x', file.path]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + } + + setUp(() { + root = Directory.systemTemp.createTempSync('dnix build inputs '); + bin = Directory(p.join(root.path, 'bin'))..createSync(); + for (final directory in ['linux', 'assets/windows', 'lib/wl_gen']) { + Directory(p.join(root.path, directory)).createSync(recursive: true); + } + for (final file in [ + 'scripts/build_app.sh', + 'scripts/env.sh', + 'scripts/app_config/shared/update_version.sh', + 'tool/process_pubspec_deps.dart', + 'tool/configure_native_assets.dart', + 'tool/native_build_options.dart', + 'tool/gen_interfaces.dart', + for (final app in apps) 'scripts/app_config/configure_$app.sh', + ]) { + copyFile(file); + } + copyDirectory('scripts/app_config/templates'); + copyDirectory('tool/wl_templates'); + for (final app in apps) { + final icon = File( + p.join( + root.path, + 'asset_sources/other/playstore_icon', + app, + 'app_icon-playstore.png', + ), + ); + icon.parent.createSync(recursive: true); + icon.writeAsStringSync('fixture icon'); + } + // Keep the real app/template renderers. Stub only assets and native tools, + // so this test never downloads a library or requires Nix, Xcode or Go. + script('scripts/app_config/shared/link_assets.sh', ':'); + script('scripts/app_config/shared/asset_generators.sh', r''' +cp "$APP_PROJECT_ROOT_DIR/pubspec.yaml" "$APP_PROJECT_ROOT_DIR/before-assets.yaml" +'''); + for (final platform in ['macos', 'windows', 'linux']) { + script('scripts/app_config/platforms/$platform/platform_config.sh', ':'); + } + for (final dependency in ['secure_storage_deps', 'secp256k1']) { + script('scripts/linux/build_$dependency.sh', ''' +printf '%s\\n' '$dependency' >> "\$APP_PROJECT_ROOT_DIR/linux-deps" +pwd > "\$APP_PROJECT_ROOT_DIR/native-cwd" +'''); + } + script('bin/git', r''' +test "$1" = log +printf '0000000000000000000000000000000000000000\n' +'''); + // Invoke this SDK directly; Flutter's launcher also calls our fake git. + final dart = Platform.resolvedExecutable.replaceAll("'", "'\\''"); + script('bin/dart', "exec '$dart' \"\$@\""); + script('bin/sha256sum', "printf 'fixture-hash mwebd.exe\\n'"); + script( + 'bin/flutter', + r'''printf '%s\n' "$*" >> "$APP_PROJECT_ROOT_DIR/flutter-calls"''', + ); + // build_app.sh configures Windows only from Git Bash. + script('bin/uname', r''' +if [ "${1:-}" = -s ] && [ -n "${DNIX_TEST_UNAME:-}" ]; then + printf '%s\n' "$DNIX_TEST_UNAME" +else + exec /usr/bin/uname "$@" +fi +'''); + // The real preflight/helper have separate tests with injected native tools. + // These fixtures exercise their position in the configuration flow and cwd. + File(p.join(root.path, 'tool/check_native_overrides.dart')) + .writeAsStringSync(''' +import 'dart:convert'; +import 'dart:io'; +void main(List args) { + if (args.length != 1) exit(64); + final root = File.fromUri(Platform.script).parent.parent; + final calls = File('\${root.path}/override-checks'); + final count = calls.existsSync() ? calls.readAsLinesSync().length : 0; + calls.writeAsStringSync('\${jsonEncode(args)}\\n', mode: FileMode.append); + if (Platform.environment['DNIX_TEST_OVERRIDE_FAIL'] == '\${count + 1}') { + exit(23); + } +} +'''); + File(p.join(root.path, 'tool/check_build_environment.dart')) + .writeAsStringSync(''' +import 'dart:convert'; +import 'dart:io'; +void main(List args) { + final root = File.fromUri(Platform.script).parent.parent; + File('\${root.path}/preflight-args').writeAsStringSync(jsonEncode(args)); + if (Platform.environment['DNIX_TEST_PREFLIGHT_FAIL'] == '1') exit(21); +} +'''); + final windowsHelper = File( + p.join(root.path, 'scripts/windows/build_secp256k1.dart'), + ); + windowsHelper.parent.createSync(recursive: true); + windowsHelper.writeAsStringSync(''' +import 'dart:io'; +void main(List args) { + final root = File.fromUri(Platform.script).parent.parent.parent; + if (args.contains('--check')) { + if (Platform.environment['DNIX_TEST_SECP_PREFLIGHT_FAIL'] == '1') exit(22); + return; + } + File('\${root.path}/native-cwd').writeAsStringSync(Directory.current.resolveSymbolicLinksSync()); + final output = File('\${root.path}/build/secp256k1.dll'); + output.parent.createSync(recursive: true); + output.writeAsStringSync('fixture DLL'); +} +'''); + File(p.join(root.path, 'tool/build_standalone_mwebd_windows.dart')) + .writeAsStringSync(''' +import 'dart:io'; +void main(List args) { + final root = File.fromUri(Platform.script).parent.parent; + File('\${root.path}/assets/windows/mwebd.exe').writeAsBytesSync([0]); +} +'''); + }); + tearDown(() => root.delete(recursive: true)); + + Future configure( + String app, { + bool prebuilt = false, + bool skipNative = true, + String platform = 'macos', + String? distribution, + bool fromScripts = false, + Map environment = const {}, + int expectedExit = 0, + }) async { + final overrideChecks = File(p.join(root.path, 'override-checks')); + final previousChecks = overrideChecks.existsSync() + ? overrideChecks.readAsLinesSync().length + : 0; + final process = await Process.start( + 'bash', + [ + fromScripts ? 'build_app.sh' : 'scripts/build_app.sh', + '-a', + app, + '-p', + platform, + '-v', + '1.2.3', + '-b', + '123', + if (prebuilt) '-d', + if (skipNative) '-i', + if (distribution != null) ...['-w', distribution], + ], + workingDirectory: fromScripts ? p.join(root.path, 'scripts') : root.path, + environment: { + 'PATH': '${bin.path}:${Platform.environment['PATH']}', + 'DNIX_WSL_DISTRIBUTION': '', + if (platform == 'windows') 'DNIX_TEST_UNAME': 'MINGW64_NT-10.0', + ...environment, + }, + ); + final output = process.stdout.transform(utf8.decoder).join(); + final errors = process.stderr.transform(utf8.decoder).join(); + process.stdin.writeln('yes'); + await process.stdin.close(); + final code = await process.exitCode; + expect(code, expectedExit, reason: '${await output}\n${await errors}'); + if (expectedExit == 0) { + final calls = overrideChecks.readAsLinesSync(); + expect(calls, hasLength(previousChecks + 2)); + for (final call in calls.skip(previousChecks)) { + expect(jsonDecode(call), [app]); + } + final pubspec = loadYaml( + File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(), + ) as YamlMap; + final dependencies = (pubspec['dependencies'] as YamlMap).keys.toSet(); + expect(dependencies.intersection(allPackages), enabledDnixPackages(app)); + } + } + + void expectMode(String mode, {String? distribution, bool linux = false}) { + final text = File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(); + final pubspec = loadYaml(text) as YamlMap; + final defines = (pubspec['hooks'] as YamlMap)['user_defines'] as YamlMap; + expect(defines.keys.toSet(), allPackages); + for (final package in allPackages) { + expect(defines[package], { + 'build': mode, + if (distribution != null) 'wsl_distribution': distribution, + }); + } + expect(text, isNot(contains('PLACEHOLDER_DNIX_BUILD_MODE'))); + expect(text, isNot(contains('native_build:'))); + expect(text, isNot(contains('prebuilt_manifest_'))); + expect(text, isNot(contains('PLACEHOLDER_DNIX_WSL_DISTRIBUTION'))); + final assets = File(p.join(root.path, 'before-assets.yaml')); + if (linux) { + expect(assets.existsSync(), isFalse); + } else { + // Asset tools see the final spec, though their own dependency closures + // contain no application native hooks. + expect(assets.readAsStringSync(), text); + } + } + + group('dnix configuration', () { + for (final app in apps) { + test( + '$app switches source -> prebuilt -> source without stale settings', + () async { + for (final prebuilt in [false, true, false]) { + await configure(app, prebuilt: prebuilt); + expectMode(prebuilt ? 'prebuilt' : 'source'); + } + }, + ); + } + + for (final prebuilt in [false, true]) { + test( + 'Windows config builds secp in project root, -d=$prebuilt', + () async { + await configure( + 'stack_wallet', + platform: 'windows', + skipNative: false, + prebuilt: prebuilt, + fromScripts: true, + ); + expectMode(prebuilt ? 'prebuilt' : 'source'); + expect( + File(p.join(root.path, 'build/secp256k1.dll')).existsSync(), + isTrue, + ); + }, + ); + } + + for (final prebuilt in [false, true]) { + test('Linux config builds both external deps, -d=$prebuilt', () async { + await configure( + 'stack_wallet', + platform: 'linux', + skipNative: false, + prebuilt: prebuilt, + ); + expectMode(prebuilt ? 'prebuilt' : 'source', linux: true); + expect(File(p.join(root.path, 'linux-deps')).readAsLinesSync(), [ + 'secure_storage_deps', + 'secp256k1', + ]); + expect(File(p.join(root.path, 'flutter-calls')).readAsLinesSync(), [ + 'pub get', + ]); + expect( + File(p.join(root.path, 'native-cwd')).readAsStringSync().trim(), + Directory(p.join(root.path, 'scripts/linux')) + .resolveSymbolicLinksSync(), + ); + }); + } + + for (final platform in ['windows', 'linux']) { + test('-i skips external deps for $platform in either mode', () async { + for (final prebuilt in [false, true]) { + await configure( + 'stack_wallet', + platform: platform, + prebuilt: prebuilt, + ); + expect(File(p.join(root.path, 'native-cwd')).existsSync(), isFalse); + expect( + File(p.join(root.path, 'build/secp256k1.dll')).existsSync(), + isFalse, + ); + expect(File(p.join(root.path, 'linux-deps')).existsSync(), isFalse); + } + }); + } + + test( + 'WSL environment survives regeneration, flag overrides, unset clears', + () async { + const distro = 'Ubuntu "dev" & %20'; + for (final prebuilt in [false, true]) { + await configure( + 'stack_wallet', + prebuilt: prebuilt, + environment: {'DNIX_WSL_DISTRIBUTION': distro}, + ); + expectMode(prebuilt ? 'prebuilt' : 'source', distribution: distro); + expect( + jsonDecode( + File(p.join(root.path, 'preflight-args')).readAsStringSync(), + ), + ['macos', 'stack_wallet', prebuilt ? 'prebuilt' : 'source', distro], + ); + } + await configure( + 'stack_wallet', + distribution: 'Ubuntu-24.04', + environment: {'DNIX_WSL_DISTRIBUTION': distro}, + ); + expectMode('source', distribution: 'Ubuntu-24.04'); + await configure('stack_wallet'); + expectMode('source'); + }, + ); + + for (final failure in [ + (variable: 'DNIX_TEST_OVERRIDE_FAIL', code: 23), + (variable: 'DNIX_TEST_PREFLIGHT_FAIL', code: 21), + (variable: 'DNIX_TEST_SECP_PREFLIGHT_FAIL', code: 22), + ]) { + test( + '${failure.variable} leaves existing configuration untouched', + () async { + final spec = File(p.join(root.path, 'pubspec.yaml')) + ..writeAsStringSync('unchanged'); + await configure( + 'stack_wallet', + platform: 'windows', + skipNative: false, + environment: {failure.variable: '1'}, + expectedExit: failure.code, + ); + expect(spec.readAsStringSync(), 'unchanged'); + expect( + File(p.join(root.path, 'lib/app_config.g.dart')).existsSync(), + isFalse, + ); + }, + ); + } + + test('post-config override failure stops before native builds', () async { + await configure( + 'stack_wallet', + platform: 'windows', + skipNative: false, + environment: {'DNIX_TEST_OVERRIDE_FAIL': '2'}, + expectedExit: 23, + ); + expect( + File(p.join(root.path, 'override-checks')).readAsLinesSync(), + hasLength(2), + ); + expect(File(p.join(root.path, 'native-cwd')).existsSync(), isFalse); + expect( + File(p.join(root.path, 'build/secp256k1.dll')).existsSync(), + isFalse, + ); + }); + + test('Windows configuration outside Git Bash changes nothing', () async { + final spec = File(p.join(root.path, 'pubspec.yaml')) + ..writeAsStringSync('unchanged'); + await configure( + 'stack_wallet', + platform: 'windows', + environment: {'DNIX_TEST_UNAME': 'Linux'}, + expectedExit: 1, + ); + expect(spec.readAsStringSync(), 'unchanged'); + expect(File(p.join(root.path, 'override-checks')).existsSync(), isFalse); + }); + + test('renderer rejects invalid modes and WSL names', () { + expect( + () => configureNativeAssets('', 'typo', null), + throwsArgumentError, + ); + for (final distribution in [ + '', + ' ', + '-option', + 'line\nbreak', + 'nul\u0000', + ]) { + expect( + () => configureNativeAssets('', 'source', distribution), + throwsArgumentError, + ); + } + }); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); +} diff --git a/scripts/tests/native_dependency_contract_test.dart b/scripts/tests/native_dependency_contract_test.dart new file mode 100644 index 0000000000..2120b128d8 --- /dev/null +++ b/scripts/tests/native_dependency_contract_test.dart @@ -0,0 +1,277 @@ +// Contract tests for resolved dependencies; run after flutter pub get. +// dnix is deliberately a transitive dependency of the four native packages. +// ignore_for_file: depend_on_referenced_packages +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:dnix/src/wsl_build.dart' as dnix; +import 'package:test/test.dart'; + +import '../../tool/check_build_environment.dart' show wslNixProbe; +import '../../tool/native_build_options.dart'; +import '../../tool/native_dependency_contract.dart'; + +void main() { + final repository = File.fromUri(Platform.script).parent.parent.parent; + late Directory project; + late Directory cache; + + setUp(() { + project = Directory.systemTemp.createTempSync('native contract % '); + cache = Directory.fromUri(project.uri.resolve('pub cache/'))..createSync(); + for (final relative in [ + 'scripts/app_config/templates/pubspec.template.yaml', + 'tool/native_dependency_compatibility.json', + ]) { + final destination = File.fromUri(project.uri.resolve(relative)); + destination.parent.createSync(recursive: true); + File.fromUri(repository.uri.resolve(relative)).copySync(destination.path); + } + }); + tearDown(() => project.deleteSync(recursive: true)); + + Future requirement({ + String version = '27.0', + String? build, + }) => loadXcodeRequirement( + project, + readMetadata: (_) async => { + 'toolchains': {'xcode': version, if (build != null) 'xcodeBuild': build}, + }, + ); + + test( + 'pins are read from the template, including the tor repository name', + () { + final pins = readNativeDependencies(project); + expect(pins.map((pin) => pin.name).toSet(), dnixPackageNames); + expect( + pins.singleWhere((pin) => pin.name == 'tor_ffi_plugin').repository, + 'tor', + ); + expect(pins.every((pin) => pin.revision.length == 40), isTrue); + }, + ); + + test('known version-only pins use explicit reviewed compatibility', () async { + final actual = await requirement(); + expect(actual.version, '27.0'); + expect(actual.build, '27A266a'); + expect(actual.upstreamBuildPinned, isFalse); + }); + + test('upstream version and build supersede compatibility', () async { + final actual = await requirement(version: '28.1', build: '28B123'); + expect(actual.version, '28.1'); + expect(actual.build, '28B123'); + expect(actual.upstreamBuildPinned, isTrue); + }); + + test('Xcode metadata is loaded only for the selected app', () async { + for (final entry in const { + 'stack_wallet': dnixPackageNames, + 'stack_duo': {'frostdart', 'tor_ffi_plugin'}, + 'campfire': {'tor_ffi_plugin'}, + }.entries) { + final loaded = []; + await loadXcodeRequirement( + project, + app: entry.key, + readMetadata: (dependency) async { + expect(entry.value, contains(dependency.name)); + loaded.add(dependency.name); + return { + 'toolchains': {'xcode': '28.1', 'xcodeBuild': '28B123'}, + }; + }, + ); + expect(loaded, unorderedEquals(entry.value), reason: entry.key); + } + }); + + test('new version-only revision cannot inherit the previous build', () async { + final template = File.fromUri( + project.uri.resolve('scripts/app_config/templates/pubspec.template.yaml'), + ); + final previous = readNativeDependencies(project).first.revision; + template.writeAsStringSync( + template.readAsStringSync().replaceAll( + previous, + List.filled(40, '0').join(), + ), + ); + await expectLater(requirement(), throwsFormatException); + await expectLater(requirement(version: '28.1', build: '28B123'), completes); + }); + + test('inconsistent package requirements fail', () async { + await expectLater( + loadXcodeRequirement( + project, + readMetadata: (dependency) async => { + 'toolchains': { + 'xcode': '28.1', + 'xcodeBuild': dependency.name == 'frostdart' ? '28B123' : '28B124', + }, + }, + ), + throwsFormatException, + ); + }); + + test('override guard checks exact pins and only enabled packages', () { + final frost = readNativeDependencies(project) + .singleWhere((dependency) => dependency.name == 'frostdart'); + final override = File.fromUri( + frost.cachedDirectory(cache).uri.resolve('.dnix-build'), + ); + override.parent.createSync(recursive: true); + override.writeAsStringSync('dev'); + final environment = {'PUB_CACHE': cache.path}; + expect( + nativeBuildOverrides(project, 'campfire', environment: environment), + isEmpty, + ); + expect( + nativeBuildOverrides(project, 'stack_duo', environment: environment), + [override.path], + ); + override.writeAsStringSync('source'); + expect( + nativeBuildOverrides(project, 'stack_wallet', environment: environment), + [override.path], + reason: 'Even matching overrides can hide later mode changes', + ); + }); + + test('resolved override paths decode spaces and percent characters', () { + final package = Directory.fromUri(project.uri.resolve('local%20tor%20%25/')) + ..createSync(); + final override = File.fromUri(package.uri.resolve('.dnix-build')) + ..writeAsStringSync('prebuilt'); + final config = File.fromUri( + project.uri.resolve('.dart_tool/package_config.json'), + ); + config.parent.createSync(); + config.writeAsStringSync( + jsonEncode({ + 'configVersion': 2, + 'packages': [ + {'name': 'tor_ffi_plugin', 'rootUri': '../local%20tor%20%25/'}, + ], + }), + ); + expect( + nativeBuildOverrides( + project, + 'campfire', + environment: {'PUB_CACHE': cache.path}, + ), + [override.path], + ); + }); + + test('Windows default cache and PUB_CACHE override use the right root', () { + expect( + pubCacheDirectory({'LOCALAPPDATA': '/users/test/local'}, 'windows')!.path, + Directory('/users/test/local/Pub/Cache').path, + ); + expect( + pubCacheDirectory({'PUB_CACHE': '/explicit'}, 'windows')!.path, + Directory('/explicit').path, + ); + }); + + test( + 'resolved package metadata agrees with current Xcode compatibility', + () async { + final actual = await loadXcodeRequirement( + repository, + readMetadata: (dependency) async { + final uri = await Isolate.resolvePackageUri( + Uri.parse('package:${dependency.name}/'), + ); + expect( + uri, + isNotNull, + reason: 'Run flutter pub get for Stack Wallet first', + ); + final root = Directory.fromUri(uri!).parent; + expect( + root.path, + endsWith('${dependency.repository}-${dependency.revision}'), + ); + return jsonDecode( + File.fromUri(root.uri.resolve('dnix.json')).readAsStringSync(), + ) as Map; + }, + ); + expect(actual.version, isNotEmpty); + expect(actual.build, isNotEmpty); + }, + ); + + test( + 'WSL validator agrees with dnix on accepted names and common errors', + () { + for (final value in [ + null, + 'Ubuntu-24.04', + ' Custom distro ', + 'Distro & name', + ]) { + expect(validatedDistribution(value), dnix.parseWslDistribution(value)); + } + for (final value in ['', ' ', 'Ubuntu\u0000']) { + expect(() => validatedDistribution(value), throwsArgumentError); + expect( + () => dnix.parseWslDistribution(value), + throwsA(isA()), + ); + } + }, + ); + + test( + 'WSL probe tracks the resolved dnix launcher and Nix profile search', + () async { + final destination = File.fromUri( + project.uri.resolve('output/library.dll'), + ); + late List arguments; + await dnix.buildWithWsl( + packagePath: r'C:\source with spaces', + target: 'x86_64-pc-windows-msvc', + fileName: 'library.dll', + destination: destination, + distribution: ' Ubuntu-24.04 ', + runCommand: (executable, args) async { + expect(executable, 'wsl.exe'); + arguments = args; + destination.writeAsStringSync('fixture'); + return 0; + }, + ); + expect(arguments.take(7), [ + '--distribution', + 'Ubuntu-24.04', + '--cd', + '/', + '--exec', + '/bin/sh', + '-c', + ]); + final upstreamScript = arguments[7]; + final search = RegExp(r'if ! command -v nix[\s\S]+?\nfi') + .firstMatch(wslNixProbe)! + .group(0)!; + expect( + upstreamScript, + contains(search), + reason: 'Review the preflight when dnix changes its Nix search', + ); + expect(upstreamScript, contains('command -v wslpath')); + }, + ); +} diff --git a/scripts/tests/prebuild_windows_test.ps1 b/scripts/tests/prebuild_windows_test.ps1 new file mode 100644 index 0000000000..cdfcf5bd57 --- /dev/null +++ b/scripts/tests/prebuild_windows_test.ps1 @@ -0,0 +1,64 @@ +# Run with: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/prebuild_windows_test.ps1 +# No Flutter, package dependencies, credentials, or native libraries required. +$ErrorActionPreference = 'Stop' +$sourceScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'prebuild.ps1' +$fixtureBase = Join-Path ([System.IO.Path]::GetTempPath()) ('stack prebuild [paths] ' + [Guid]::NewGuid()) +$coins = @('bitcoin', 'bitcoincash', 'dogecoin', 'namecoin', 'firo', 'particl') +$expectedParameters = @' +const TEST_MNEMONIC = ""; +const ROOT_WIF = ""; +const NODE_WIF_84 = ""; +'@ + +function Assert($condition, $message) { + if (-not $condition) { throw $message } +} + +function New-Fixture($name) { + $root = Join-Path $fixtureBase $name + foreach ($relative in @('scripts', 'lib', 'elsewhere')) { + [System.IO.Directory]::CreateDirectory((Join-Path $root $relative)) | Out-Null + } + foreach ($coin in $coins) { + [System.IO.Directory]::CreateDirectory((Join-Path $root "test/services/coins/$coin")) | Out-Null + } + Copy-Item -LiteralPath $sourceScript -Destination (Join-Path $root 'scripts/prebuild.ps1') + return $root +} + +function Parameters-Path($root, $coin) { + return Join-Path $root "test/services/coins/$coin/${coin}_wallet_test_parameters.dart" +} + +try { + foreach ($cwd in @('.', 'elsewhere')) { + $root = New-Fixture ('cwd-' + [Guid]::NewGuid()) + Push-Location -LiteralPath (Join-Path $root $cwd) + try { & (Join-Path $root 'scripts/prebuild.ps1') } finally { Pop-Location } + Assert (Test-Path -LiteralPath (Join-Path $root 'lib/external_api_keys.dart') -PathType Leaf) 'API key stub was not written in the checkout.' + foreach ($coin in $coins) { + $actual = Get-Content -LiteralPath (Parameters-Path $root $coin) -Raw + Assert ($actual.TrimEnd() -eq $expectedParameters.TrimEnd()) "Invalid Dart wallet stub for $coin." + } + Assert (-not (Test-Path -LiteralPath (Join-Path $fixtureBase 'lib/external_api_keys.dart'))) 'prebuild wrote outside its checkout.' + } + + $root = New-Fixture 'preserve' + $keys = Join-Path $root 'lib/external_api_keys.dart' + $parameters = Parameters-Path $root 'bitcoin' + Set-Content -LiteralPath $keys -Value 'existing keys' + Set-Content -LiteralPath $parameters -Value 'existing wallet parameters' + & (Join-Path $root 'scripts/prebuild.ps1') + Assert ((Get-Content -LiteralPath $keys -Raw).TrimEnd() -eq 'existing keys') 'Existing keys were overwritten.' + Assert ((Get-Content -LiteralPath $parameters -Raw).TrimEnd() -eq 'existing wallet parameters') 'Existing parameters were overwritten.' + + $root = New-Fixture 'write-failure' + [System.IO.Directory]::CreateDirectory((Parameters-Path $root 'bitcoin')) | Out-Null + $failed = $false + try { & (Join-Path $root 'scripts/prebuild.ps1') } catch { $failed = $true } + Assert $failed 'A directory at a wallet stub path must fail the script.' + Assert (-not (Test-Path -LiteralPath (Parameters-Path $root 'bitcoincash'))) 'prebuild continued writing after a failure.' + Write-Host 'All 4 PowerShell prebuild tests passed.' +} finally { + if (Test-Path -LiteralPath $fixtureBase) { Remove-Item -LiteralPath $fixtureBase -Recurse -Force } +} diff --git a/scripts/tests/windows_secp_build_test.dart b/scripts/tests/windows_secp_build_test.dart new file mode 100644 index 0000000000..be724bada1 --- /dev/null +++ b/scripts/tests/windows_secp_build_test.dart @@ -0,0 +1,292 @@ +// Run with: dart scripts/tests/windows_secp_build_test.dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../windows/build_secp256k1.dart'; + +void main() { + late Directory root; + late String visualStudio; + late String programFiles; + late String sdkRoot; + late List<({String executable, List args, String? cwd})> calls; + late String cmakeVersion; + String? failAt; + bool missingDll = false; + bool cleanupLocked = false; + Encoding? vswhereEncoding; + + test('secp pin matches the resolved coinlib Windows recipe', () async { + // Resolve the dependency selected by pub, not a copied pin or cache path. + // Updating coinlib must review this contract too. + final library = await Isolate.resolvePackageUri( + Uri.parse('package:coinlib/coinlib.dart'), + ); + expect(library, isNotNull, reason: 'Run flutter pub get before this test.'); + final native = File.fromUri(library!.resolve('../bin/build_windows.dart')) + .readAsStringSync(); + final nativePin = RegExp( + r'''["']checkout["']\s*,\s*["']([0-9a-f]{40})["']''', + ).firstMatch(native)?.group(1); + expect( + nativePin, + secpRevision, + reason: 'Review the resolved coinlib native Windows secp revision.', + ); + }); + + void installSdk(String version) { + for (final relative in [ + 'Include/$version/um/Windows.h', + 'Include/$version/shared/sdkddkver.h', + 'Include/$version/ucrt/stdlib.h', + 'Lib/$version/um/x64/kernel32.lib', + 'Lib/$version/ucrt/x64/ucrt.lib', + 'bin/$version/x64/rc.exe', + 'bin/$version/x64/mt.exe', + ]) { + final file = File('$sdkRoot/$relative'); + file.parent.createSync(recursive: true); + file.writeAsStringSync('SDK fixture'); + } + } + + setUp(() { + root = Directory.systemTemp.createTempSync( + 'windows secp path with spaces ', + ); + programFiles = '${root.path}/Program Files (x86)'; + visualStudio = '${root.path}/Visual Studio'; + sdkRoot = '${root.path}/Standalone SDK'; + installSdk('10.0.22621.0'); + calls = []; + cmakeVersion = 'cmake version 3.31.6'; + failAt = null; + missingDll = false; + cleanupLocked = false; + vswhereEncoding = null; + for (final entry in { + 'Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe': + 'fixture', + 'Visual Studio/VC/Auxiliary/Build/Microsoft.VCToolsVersion.default.txt': + '14.44.1', + 'Visual Studio/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe': + 'fixture', + }.entries) { + final file = File('${root.path}/${entry.key}'); + file.parent.createSync(recursive: true); + file.writeAsStringSync(entry.value); + } + }); + tearDown(() => root.delete(recursive: true)); + + WindowsSecpBuilder builder() => WindowsSecpBuilder( + root: root, + environment: {'ProgramFiles(x86)': programFiles}, + cleanup: (directory) { + if (cleanupLocked) { + throw FileSystemException('fixture locked directory', directory.path); + } + directory.deleteSync(recursive: true); + }, + run: (executable, args, {workingDirectory, stdoutEncoding}) async { + calls.add((executable: executable, args: args, cwd: workingDirectory)); + ProcessResult result([String out = '', int status = 0]) => + ProcessResult(0, status, out, status == 0 ? '' : 'fixture failure'); + if (executable.endsWith('vswhere.exe')) { + vswhereEncoding = stdoutEncoding; + return result(visualStudio); + } + if (executable == 'reg.exe') { + // A standalone SDK registered only in the 32-bit registry view. + return args.contains('/reg:64') + ? result('', 1) + : result(' KitsRoot10 REG_SZ $sdkRoot\n'); + } + if (args.length == 1 && args.single == '--version') { + if (executable == 'cmake') { + return result('cmake version 3.12.0'); + } + return result( + executable.contains('cmake') ? cmakeVersion : 'tool version', + ); + } + final operation = args.first; + if (operation == failAt) return result('', 42); + if (executable == 'git') { + if (operation == 'init') { + Directory(args.last).createSync(recursive: true); + } else if (operation != 'checkout' && operation != 'fetch') { + fail('Unexpected git invocation: $args'); + } + return result(); + } + if (executable.contains('cmake')) { + if (operation == '-S') return result(); + if (operation == '--build') { + if (!missingDll) { + final file = File( + '${args[1]}/src/RelWithDebInfo/libsecp256k1-2.dll', + ); + file.parent.createSync(recursive: true); + file.writeAsStringSync('fixture Windows library'); + } + return result(); + } + } + fail('Unexpected command: $executable $args'); + }, + ); + + List<({String executable, List args, String? cwd})> operation( + String arg, + ) => calls.where((c) => c.args.isNotEmpty && c.args.first == arg).toList(); + + test('preflight discovers VS CMake without creating build outputs', () async { + await builder().check(); + expect(Directory('${root.path}/build').existsSync(), isFalse); + expect(operation('fetch'), isEmpty); + expect( + calls.singleWhere((c) => c.executable.endsWith('vswhere.exe')).args, + contains('-utf8'), + ); + expect(vswhereEncoding, utf8); + expect(calls.any((c) => c.executable == 'cmake'), isFalse); + expect( + calls.any( + (c) => + c.executable == + '$visualStudio/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe', + ), + isTrue, + ); + }); + + test('missing Windows SDK fails preflight before creating outputs', () async { + Directory(sdkRoot).deleteSync(recursive: true); + await expectLater(builder().check(), throwsA(isA())); + expect(Directory('${root.path}/build').existsSync(), isFalse); + expect(operation('fetch'), isEmpty); + }); + + test('incomplete x64 SDK is rejected', () async { + File('$sdkRoot/Lib/10.0.22621.0/um/x64/kernel32.lib').deleteSync(); + await expectLater(builder().check(), throwsA(isA())); + expect(Directory('${root.path}/build').existsSync(), isFalse); + }); + + test('rejects unsupported CMake', () async { + cmakeVersion = 'cmake version 3.20.6'; + await expectLater(builder().check(), throwsA(isA())); + expect(Directory('${root.path}/build').existsSync(), isFalse); + }); + + test( + 'native build pins v0.5.0 and writes DLL relative to repository', + () async { + await builder().build(); + expect(operation('checkout').single.args, [ + 'checkout', + '--detach', + secpRevision, + ]); + expect(operation('fetch').single.args, [ + 'fetch', + '--depth', + '1', + '--no-tags', + 'https://github.com/bitcoin-core/secp256k1.git', + secpRevision, + ]); + final checkout = operation('checkout').single.cwd!; + expect( + p.isWithin('${root.path}/build', checkout), + isTrue, + reason: checkout, + ); + final configure = operation('-S').single.args; + expect( + configure, + containsAll([ + 'Visual Studio 17 2022', + '-A', + 'x64', + '-DSECP256K1_ENABLE_MODULE_RECOVERY=ON', + '-DCMAKE_SYSTEM_VERSION=10.0.22621.0', + ]), + ); + expect( + File('${root.path}/build/secp256k1.dll').readAsStringSync(), + 'fixture Windows library', + ); + expect(Directory('${root.path}/build').listSync(), hasLength(1)); + }, + ); + + test('an existing DLL is rebuilt on every invocation', () async { + await builder().build(); + calls.clear(); + await builder().build(); + expect(operation('fetch'), hasLength(1)); + expect(operation('--build'), hasLength(1)); + expect( + File('${root.path}/build/secp256k1.dll').readAsStringSync(), + 'fixture Windows library', + ); + }); + + test('the newest complete SDK is selected for CMake', () async { + installSdk('10.0.26100.0'); + await builder().build(); + expect( + operation('-S').single.args, + contains('-DCMAKE_SYSTEM_VERSION=10.0.26100.0'), + ); + }); + + for (final operation in ['init', 'fetch', 'checkout', '-S', '--build']) { + test('$operation failure propagates without publishing a DLL', () async { + failAt = operation; + await expectLater(builder().build(), throwsA(isA())); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isFalse); + }); + } + + test( + 'a missing built DLL is rejected and preserves the previous DLL', + () async { + final output = File('${root.path}/build/secp256k1.dll'); + output.parent.createSync(recursive: true); + output.writeAsStringSync('previous Windows library'); + missingDll = true; + await expectLater(builder().build(), throwsA(isA())); + expect(output.readAsStringSync(), 'previous Windows library'); + }, + ); + + test('locked cleanup leaves a successfully published DLL usable', () async { + cleanupLocked = true; + await builder().build(); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isTrue); + }); + + test('locked cleanup never masks the original build failure', () async { + cleanupLocked = true; + failAt = '--build'; + await expectLater( + builder().build(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('--build'), + ), + ), + ); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isFalse); + }); +} diff --git a/scripts/tests/xcode_setup_test.dart b/scripts/tests/xcode_setup_test.dart new file mode 100644 index 0000000000..51a75646d5 --- /dev/null +++ b/scripts/tests/xcode_setup_test.dart @@ -0,0 +1,415 @@ +// Run with: dart scripts/tests/xcode_setup_test.dart +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../tool/setup_xcode.dart' as setup; + +void main() { + const fixtureVersion = '27.0'; + const fixtureBuild = '27A266a'; + late Directory root; + late Directory apps; + late Directory bin; + late File calls; + late setup.SetupTools tools; + + void script(String path, String body) { + final file = File(path); + file.parent.createSync(recursive: true); + file.writeAsStringSync('#!/bin/bash\nset -eu\n$body\n'); + expect(Process.runSync('chmod', ['+x', path]).exitCode, 0); + } + + void xcode( + String name, { + String version = fixtureVersion, + String build = fixtureBuild, + }) { + final contents = '${apps.path}/$name/Contents'; + Directory(contents).createSync(recursive: true); + File('$contents/Info.plist').writeAsStringSync(version); + File('$contents/version.plist').writeAsStringSync(build); + script('$contents/Developer/usr/bin/xcodebuild', r''' +case "$1" in + -version) printf 'Xcode %s\nBuild version %s\n' "${REPORTED_VERSION:-27.0}" "${REPORTED_BUILD:-27A266a}" ;; + -runFirstLaunch) echo setup >> "$CALLS"; exit "${SETUP_EXIT:-0}" ;; + -checkFirstLaunchStatus) echo checked >> "$CALLS" ;; + *) exit 99 ;; +esac +'''); + } + + setUp(() { + root = Directory.systemTemp.createTempSync("xcode setup ' quotes "); + apps = Directory('${root.path}/Applications')..createSync(); + bin = Directory('${root.path}/bin')..createSync(); + calls = File('${root.path}/calls'); + tools = setup.SetupTools( + applications: apps.path, + id: '${bin.path}/id', + uname: '${bin.path}/uname', + plistBuddy: '${bin.path}/PlistBuddy', + sudo: '${bin.path}/sudo', + ); + script(tools.id, 'echo "\${FIXTURE_UID:-1000}"'); + script(tools.uname, 'echo arm64'); + script(tools.plistBuddy, r'cat "$3"'); + script(tools.sudo, r''' +# No actual elevation: only our fixture xcodebuild may run. +test "$#" = 3 +test "$1" = -k +test "$3" = -runFirstLaunch +case "$2" in "$FIXTURE_APPS"/*/Contents/Developer/usr/bin/xcodebuild) ;; *) exit 98;; esac +echo sudo-without-cache >> "$CALLS" +shift +"$@" +'''); + script('${bin.path}/xcodes', r''' +if [ "$*" = 'install --help' ]; then + if [ "${HELP_EXIT:-0}" != 0 ]; then exit "$HELP_EXIT"; fi + echo "${HELP_FLAG:---no-superuser}" + exit 0 +fi +test "$#" = 6 +test "$1" = install +test "$2" = 27.0 +test "$3" = --directory +test "$4" = "$FIXTURE_APPS" +test "$5" = --no-superuser +test "$6" = --update +echo download-without-sudo >> "$CALLS" +if [ "${DOWNLOAD_EXIT:-0}" != 0 ]; then exit "$DOWNLOAD_EXIT"; fi +cp -R "$SPARE" "$4/Xcode-27.0.0.app" +'''); + xcode('Spare.app'); + Directory('${apps.path}/Spare.app').renameSync('${root.path}/Spare.app'); + }); + tearDown(() => root.delete(recursive: true)); + + Future runInstaller({ + Map env = const {}, + bool interactive = false, + String? xcodes, + }) { + // Pass fixture dependencies explicitly. No string substitution can silently + // fail and leave a real privileged command in the script under test. + for (final executable in [ + tools.id, + tools.uname, + tools.plistBuddy, + tools.sudo, + ]) { + expect(executable, startsWith('${bin.path}/')); + expect(File(executable).existsSync(), isTrue); + } + final installer = setup.installerScript( + home: root.path, + user: 'fixture', + version: fixtureVersion, + build: fixtureBuild, + xcodes: xcodes ?? '${bin.path}/xcodes', + tools: tools, + interactive: interactive, + ); + return Process.run( + '/bin/bash', + ['--noprofile', '--norc', '-c', installer], + includeParentEnvironment: false, + environment: { + 'CALLS': calls.path, + 'FIXTURE_APPS': apps.path, + 'SPARE': '${root.path}/Spare.app', + ...env, + }, + ); + } + + group('Xcode setup', () { + test( + 'downloads without sudo, then initializes only the release build', + () async { + xcode('Xcode.app', version: '26.0'); + final result = await runInstaller(); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(calls.readAsLinesSync(), [ + 'download-without-sudo', + 'sudo-without-cache', + 'setup', + 'checked', + ]); + expect(result.stdout, contains('Setup complete')); + expect(result.stdout, contains('Xcode-27.0.0.app')); + expect( + File('${apps.path}/Xcode.app/Contents/Info.plist').readAsStringSync(), + '26.0', + ); + }, + ); + + test('same-version build conflict gives a safe manual recovery', () async { + xcode('Xcode-27.0.0-Beta.1.app', build: '27A5001a'); + final result = await runInstaller(); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + expect(result.stderr, contains('another build of the same version')); + expect(result.stderr, contains('Xcode-27.0-27A266a.app')); + expect(result.stderr, contains('left untouched')); + expect( + File('${apps.path}/Xcode-27.0.0-Beta.1.app/Contents/version.plist') + .readAsStringSync(), + '27A5001a', + ); + + // Follow the proposed recovery without uninstalling the older build. + xcode('Xcode-27.0-27A266a.app'); + final recovered = await runInstaller(); + expect(recovered.exitCode, 0, reason: '${recovered.stderr}'); + expect(calls.readAsLinesSync(), [ + 'sudo-without-cache', + 'setup', + 'checked', + ]); + }); + + test( + 'skips a sorting-first beta and reuses a manually installed release', + () async { + xcode('Xcode-27.0-Beta.app', build: '27A5001a'); + xcode("Xcode-27.0-user's.app"); + File('${bin.path}/xcodes').deleteSync(); + final result = await runInstaller(); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(calls.readAsLinesSync(), [ + 'sudo-without-cache', + 'setup', + 'checked', + ]); + expect(result.stdout, contains("Xcode-27.0-user's.app")); + }, + ); + + test('download failure never reaches sudo', () async { + final result = await runInstaller(env: {'DOWNLOAD_EXIT': '42'}); + expect(result.exitCode, 42); + expect(calls.readAsLinesSync(), ['download-without-sudo']); + expect(result.stdout, isNot(contains('Setup complete'))); + }); + + test( + 'rejects xcodes without no-superuser support before downloading', + () async { + final result = await runInstaller(env: {'HELP_FLAG': 'old version'}); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }, + ); + + test('explains xcodes shim failure without forwarding build env', () async { + final result = await runInstaller(env: {'HELP_EXIT': '44'}); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + expect(result.stderr, contains('HOME with a clean setup environment')); + expect(result.stderr, contains('globally configured xcodes')); + }); + + for (final env in [ + {'REPORTED_VERSION': '26.0'}, + {'REPORTED_BUILD': '27A5001a'}, + ]) { + test( + 'rejects mismatched executable metadata: ${env.keys.single}', + () async { + xcode('Xcode-27.0.app'); + final result = await runInstaller(env: env); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }, + ); + } + + test('setup failure does not claim success', () async { + xcode('Xcode-27.0.app'); + final result = await runInstaller(env: {'SETUP_EXIT': '43'}); + expect(result.exitCode, 43); + expect(calls.readAsLinesSync(), ['sudo-without-cache', 'setup']); + expect(result.stdout, isNot(contains('Setup complete'))); + }); + + test('rejects a root session before downloading', () async { + final result = await runInstaller(env: {'FIXTURE_UID': '0'}); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }); + + test('interactive setup rejects a pipe before downloading', () async { + final result = await runInstaller(interactive: true); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }); + + test( + 'resolves nonstandard PATH installations before cleaning the environment', + () async { + final directory = Directory("${root.path}/Mint user's/bin") + ..createSync(recursive: true); + final executable = '${directory.path}/xcodes'; + File('${bin.path}/xcodes').copySync(executable); + final resolved = setup.findXcodes('.:${directory.path}:/missing'); + expect(resolved, executable); + expect(setup.findXcodes('.::relative'), isNull); + final result = await runInstaller(xcodes: resolved); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(calls.readAsLinesSync().first, 'download-without-sudo'); + }, + ); + + test( + 'preserves symlink shim dispatch name and its PATH directory', + () async { + final backend = '${root.path}/mise backend/mise'; + final shims = Directory('${root.path}/mise shims')..createSync(); + script(backend, r''' +test "${0##*/}" = xcodes +test "$(command -v xcodes)" = "$0" +exec "$FIXTURE_XCODES" "$@" +'''); + final executable = '${shims.path}/xcodes'; + Link(executable).createSync(backend); + final found = setup.findXcodes('${shims.path}:/usr/bin:/bin'); + expect(found, executable); + final result = await runInstaller( + xcodes: found, + env: {'FIXTURE_XCODES': '${bin.path}/xcodes'}, + ); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(calls.readAsLinesSync().first, 'download-without-sudo'); + }, + ); + + test( + 'missing xcodes explains caller PATH instead of assuming Homebrew', + () async { + final result = await runInstaller(xcodes: '/missing/xcodes'); + expect(result.exitCode, isNot(0)); + expect(result.stderr, contains('PATH when setup was launched')); + expect(calls.existsSync(), isFalse); + }, + ); + + for (final shell in ['/bin/bash', '/bin/tcsh']) { + test( + 'short launcher works with $shell and removes the build environment', + () async { + final temporary = Directory('/tmp') + .createTempSync('stack-xcode-setup-'); + addTearDown(() => temporary.delete(recursive: true)); + final installer = File('${temporary.path}/setup.sh') + ..writeAsStringSync('/usr/bin/env\n'); + final command = setup.terminalCommand(installer.path); + expect(command, isNot(contains('\n'))); + final result = await Process.run( + shell, + ['-f', '-c', command], + includeParentEnvironment: false, + environment: { + 'BUILD_ONLY': 'fixture', + 'XCODES_PASSWORD': 'fixture', + 'DEVELOPER_DIR': '/wrong/Xcode.app', + 'SUDO_ASKPASS': '/wrong/helper', + }, + ); + expect(result.exitCode, 0, reason: '${result.stderr}'); + final output = result.stdout as String; + for (final name in [ + 'BUILD_ONLY', + 'XCODES_PASSWORD', + 'DEVELOPER_DIR', + 'SUDO_ASKPASS', + ]) { + expect(output, isNot(contains('$name='))); + } + }, + skip: !File(shell).existsSync() ? '$shell is not installed' : false, + ); + } + + test('launcher rejects a path that a login shell could reinterpret', () { + expect( + () => setup.terminalCommand('/tmp/user name/setup.sh'), + throwsArgumentError, + ); + expect( + () => setup.terminalCommand( + '/tmp/stack-xcode-setup-abc/setup.sh;touch evil', + ), + throwsArgumentError, + ); + }); + + test('the Terminal AppleScript compiles without executing it', () async { + final command = setup.terminalCommand( + '/tmp/stack-xcode-setup-fixture/setup.sh', + ); + final appleScript = setup.terminalAppleScript(command); + // Compile only: no Terminal window or privileged command runs. + final output = '${root.path}/setup.scpt'; + final result = await Process.run('/usr/bin/osacompile', [ + '-o', + output, + '-e', + appleScript, + ]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + }, skip: !Platform.isMacOS ? 'Requires macOS osacompile' : false); + + test( + 'completion waits for acknowledgement on a real terminal', + () async { + // Use a fixture pseudo-terminal, never Terminal.app or native tools. + final probe = File('${root.path}/finish.sh') + ..writeAsStringSync( + setup.installerScript( + home: root.path, + user: 'fixture', + version: fixtureVersion, + build: fixtureBuild, + tools: tools, + interactive: true, + ), + ); + xcode('Xcode-27.0.app'); + final process = await Process.start( + '/usr/bin/script', + ['-q', '/dev/null', '/bin/bash', probe.path], + includeParentEnvironment: false, + environment: {'CALLS': calls.path, 'FIXTURE_APPS': apps.path}, + ); + final text = StringBuffer(); + var sawPrompt = false; + final subscription = process.stdout.listen((chunk) { + text.write(String.fromCharCodes(chunk)); + if (!sawPrompt && text.toString().contains('Press Return to close')) { + sawPrompt = true; + process.stdin.writeln(); + } + }); + final errors = process.stderr.drain(); + try { + expect( + await process.exitCode.timeout(const Duration(seconds: 10)), + 0, + ); + await subscription.cancel(); + await errors; + expect(sawPrompt, isTrue); + expect(text.toString(), contains('Setup complete')); + } finally { + process.kill(ProcessSignal.sigkill); + await process.stdin.close(); + } + }, + skip: !Platform.isMacOS ? 'Requires macOS script pseudo-terminal' : false, + ); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); +} diff --git a/scripts/windows/build_secp256k1.bat b/scripts/windows/build_secp256k1.bat deleted file mode 100644 index b619e6e78e..0000000000 --- a/scripts/windows/build_secp256k1.bat +++ /dev/null @@ -1,13 +0,0 @@ -if not exist "build" mkdir "build" -cd build -git clone https://github.com/bitcoin-core/secp256k1 -cd secp256k1 -git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c -git reset --hard -if exist "build" rmdir /s /q "build" -cmake -G "Visual Studio 17 2022" -A x64 -S . -B build -DSECP256K1_ENABLE_MODULE_RECOVERY=ON -cd build -cmake --build . -if not exist "..\..\..\..\..\build\" mkdir "..\..\..\..\..\build\" -xcopy bin\Debug\libsecp256k1-2.dll "..\..\..\..\..\build\secp256k1.dll" /Y -cd ..\..\..\ diff --git a/scripts/windows/build_secp256k1.dart b/scripts/windows/build_secp256k1.dart new file mode 100644 index 0000000000..575e1a78df --- /dev/null +++ b/scripts/windows/build_secp256k1.dart @@ -0,0 +1,313 @@ +// Plain Dart: usable before pub get without the app's native-assets hooks. +import 'dart:convert'; +import 'dart:io'; + +const secpRevision = 'e3a885d42a7800c1ccebad94ad1e2b82c4df5c65'; // v0.5.0 + +typedef CommandRunner = Future Function( + String executable, + List arguments, { + String? workingDirectory, + Encoding? stdoutEncoding, +}); + +Future _run( + String executable, + List arguments, { + String? workingDirectory, + Encoding? stdoutEncoding, +}) => Process.run( + executable, + arguments, + workingDirectory: workingDirectory, + stdoutEncoding: stdoutEncoding ?? systemEncoding, +); + +void _removeWork(Directory directory) => directory.deleteSync(recursive: true); + +class SecpBuildException implements Exception { + SecpBuildException(this.message); + final String message; + @override + String toString() => message; +} + +class WindowsSecpBuilder { + WindowsSecpBuilder({ + required this.root, + required this.environment, + this.run = _run, + this.cleanup = _removeWork, + }); + + final Directory root; + final Map environment; + final CommandRunner run; + final void Function(Directory) cleanup; + late String cmake; + late String visualStudio; + late String windowsSdkVersion; + + static int compareVersions(String a, String b) { + final left = a.split('.').map(int.parse).toList(); + final right = b.split('.').map(int.parse).toList(); + for (var i = 0; i < left.length || i < right.length; i++) { + final difference = + (i < left.length ? left[i] : 0) - (i < right.length ? right[i] : 0); + if (difference != 0) return difference; + } + return 0; + } + + Future findWindowsSdkVersion() async { + final roots = {}; + for (final view in ['64', '32']) { + try { + final result = await checked('reg.exe', [ + 'query', + r'HKLM\SOFTWARE\Microsoft\Windows Kits\Installed Roots', + '/v', + 'KitsRoot10', + '/reg:$view', + ]); + final value = RegExp( + r'^\s*KitsRoot10\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$', + multiLine: true, + caseSensitive: false, + ).firstMatch(result.stdout.toString())?.group(1); + if (value != null) { + roots.add( + value.replaceAllMapped(RegExp(r'%([^%]+)%'), (match) { + return environment[match.group(1)!] ?? match.group(0)!; + }), + ); + } + } on SecpBuildException { + // Standalone SDK installers may register only one registry view. + } + } + final usable = []; + for (final sdkRoot in roots) { + final include = Directory('$sdkRoot/Include'); + if (!include.existsSync()) continue; + for (final candidate in include.listSync().whereType()) { + final version = candidate.uri.pathSegments + .where((s) => s.isNotEmpty) + .last; + if (!RegExp(r'^10\.\d+\.\d+\.\d+$').hasMatch(version)) continue; + final files = [ + 'Include/$version/um/Windows.h', + 'Include/$version/shared/sdkddkver.h', + 'Include/$version/ucrt/stdlib.h', + 'Lib/$version/um/x64/kernel32.lib', + 'Lib/$version/ucrt/x64/ucrt.lib', + 'bin/$version/x64/rc.exe', + 'bin/$version/x64/mt.exe', + ]; + if (files.every((file) => File('$sdkRoot/$file').existsSync())) { + usable.add(version); + } + } + } + if (usable.isEmpty) { + throw SecpBuildException( + 'Install a Windows 10/11 SDK with x64 desktop headers, libraries, ' + 'and tools. No complete SDK was found under KitsRoot10.', + ); + } + usable.sort((a, b) => compareVersions(b, a)); + return usable.first; + } + + Future checked( + String executable, + List arguments, { + String? workingDirectory, + Encoding? stdoutEncoding, + }) async { + final ProcessResult result; + try { + result = await run( + executable, + arguments, + workingDirectory: workingDirectory, + stdoutEncoding: stdoutEncoding, + ); + } on ProcessException catch (error) { + throw SecpBuildException('Cannot run $executable: ${error.message}'); + } + if (result.exitCode != 0) { + throw SecpBuildException( + '$executable ${arguments.join(' ')} failed (${result.exitCode}).\n' + '${result.stdout}\n${result.stderr}', + ); + } + return result; + } + + // No checkout, output creation, or compilation: safe before app config. + Future check() async { + await checked('git', ['--version']); + final programFiles = environment['ProgramFiles(x86)']; + if (programFiles == null) { + throw SecpBuildException( + 'ProgramFiles(x86) is missing. Run native Windows Dart.', + ); + } + final vswhere = + '$programFiles/Microsoft Visual Studio/Installer/vswhere.exe'; + if (!File(vswhere).existsSync()) { + throw SecpBuildException( + 'Install Visual Studio 2022 Desktop development with C++, ' + 'including CMake tools and a Windows SDK (vswhere.exe is missing).', + ); + } + // Without -utf8, vswhere writes the console code page, which Dart would + // misread for a non-ASCII installation path. + final result = await checked(vswhere, [ + '-utf8', + '-latest', + '-products', + '*', + '-version', + '[17.0,18.0)', + '-requires', + 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', + 'installationPath', + ], stdoutEncoding: utf8); + visualStudio = result.stdout.toString().trim(); + final toolsVersion = File( + '$visualStudio/VC/Auxiliary/Build/Microsoft.VCToolsVersion.default.txt', + ); + if (visualStudio.isEmpty || !toolsVersion.existsSync()) { + throw SecpBuildException( + 'Install the Visual Studio 2022 C++ build tools and Windows SDK.', + ); + } + windowsSdkVersion = await findWindowsSdkVersion(); + final bundledCmake = + '$visualStudio/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe'; + cmake = File(bundledCmake).existsSync() ? bundledCmake : 'cmake'; + final cmakeVersion = await checked(cmake, ['--version']); + final version = RegExp(r'^cmake version (\d+\.\d+(?:\.\d+)?)') + .firstMatch(cmakeVersion.stdout.toString()) + ?.group(1); + const minimum = '3.21'; + if (version == null || compareVersions(version, minimum) < 0) { + throw SecpBuildException( + 'CMake $minimum or newer is required for Windows secp256k1 ' + '(found ${version ?? 'an unrecognized version'} at $cmake).', + ); + } + } + + Future build() async { + // --check runs before configuration in a separate process. Revalidate here + // to discover the tools and SDK actually used by this build. + await check(); + final output = File('${root.path}/build/secp256k1.dll'); + output.parent.createSync(recursive: true); + final work = output.parent.createTempSync('secp256k1-windows-'); + try { + final source = '${work.path}/source'; + final buildDir = '$source/build'; + await checked('git', ['init', source]); + await checked('git', [ + 'fetch', + '--depth', + '1', + '--no-tags', + 'https://github.com/bitcoin-core/secp256k1.git', + secpRevision, + ], workingDirectory: source); + await checked('git', [ + 'checkout', + '--detach', + secpRevision, + ], workingDirectory: source); + await checked(cmake, [ + '-S', + source, + '-B', + buildDir, + '-G', + 'Visual Studio 17 2022', + '-A', + 'x64', + '-DCMAKE_GENERATOR_INSTANCE=$visualStudio', + '-DCMAKE_SYSTEM_VERSION=$windowsSdkVersion', + '-DCMAKE_BUILD_TYPE=RelWithDebInfo', + '-DBUILD_SHARED_LIBS=ON', + '-DSECP256K1_ENABLE_MODULE_RECOVERY=ON', + '-DSECP256K1_BUILD_TESTS=OFF', + '-DSECP256K1_BUILD_EXHAUSTIVE_TESTS=OFF', + '-DSECP256K1_BUILD_CTIME_TESTS=OFF', + '-DSECP256K1_BUILD_BENCHMARK=OFF', + '-DSECP256K1_BUILD_EXAMPLES=OFF', + ]); + await checked(cmake, [ + '--build', + buildDir, + '--config', + 'RelWithDebInfo', + '--parallel', + ]); + final library = File('$buildDir/src/RelWithDebInfo/libsecp256k1-2.dll'); + if (!library.existsSync() || library.lengthSync() == 0) { + throw SecpBuildException( + 'CMake did not produce the expected DLL: ${library.path}', + ); + } + // The temporary DLL is on the destination filesystem. Publish it only + // after a successful build, preserving any previous DLL on build failure. + library.renameSync(output.path); + stdout.writeln( + 'Built ${output.path} from secp256k1 v0.5.0 ($secpRevision).', + ); + } finally { + try { + cleanup(work); + } on FileSystemException catch (error) { + // Windows scanners can keep generated files open after the compiler + // exits. Preserve either the successful publication or the build error. + stderr.writeln( + 'Could not remove ${work.path}: ${error.message}. ' + 'You can remove this temporary directory later.', + ); + } + } + } +} + +Future main(List args) async { + if (args.isNotEmpty && !(args.length == 1 && args.single == '--check')) { + stderr.writeln( + 'Usage: dart scripts/windows/build_secp256k1.dart [--check]', + ); + exitCode = 64; + return; + } + if (!Platform.isWindows) { + stderr.writeln('Windows secp256k1 requires native Windows.'); + exitCode = 1; + return; + } + final builder = WindowsSecpBuilder( + root: File.fromUri(Platform.script).parent.parent.parent, + environment: Platform.environment, + ); + try { + if (args.isEmpty) { + await builder.build(); + } else { + await builder.check(); + } + } on SecpBuildException catch (error) { + stderr.writeln('Windows secp256k1: $error'); + exitCode = 1; + } on FileSystemException catch (error) { + stderr.writeln('Windows secp256k1: ${error.message} (${error.path})'); + exitCode = 1; + } +} diff --git a/scripts/windows/build_secp256k1_wsl.sh b/scripts/windows/build_secp256k1_wsl.sh deleted file mode 100644 index cedb2bc2c1..0000000000 --- a/scripts/windows/build_secp256k1_wsl.sh +++ /dev/null @@ -1,15 +0,0 @@ -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 -cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/x86_64-w64-mingw32.toolchain.cmake -DSECP256K1_ENABLE_MODULE_RECOVERY=ON -cmake --build . -mkdir -p ../../../../../build -cp bin/libsecp256k1-2.dll "../../../../../build/secp256k1.dll" -cd ../../../ diff --git a/scripts/windows/deps.sh b/scripts/windows/deps.sh deleted file mode 100644 index bae12bceb6..0000000000 --- a/scripts/windows/deps.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -# Toolchain for cross-compiling secp256k1.dll from WSL (build_secp256k1_wsl.sh). -sudo apt install clang gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 -sudo apt install libgtk2.0-dev - -wait -echo "Done building" diff --git a/tool/build_standalone_mwebd_windows.dart b/tool/build_standalone_mwebd_windows.dart index fe81b13d00..4531b7a636 100644 --- a/tool/build_standalone_mwebd_windows.dart +++ b/tool/build_standalone_mwebd_windows.dart @@ -9,6 +9,12 @@ Future main(List args) async { if (args.contains("--fetch")) { await _fetchPrebuilt(projectToolDir); + } else if (!Platform.isWindows) { + stderr.writeln( + "Building mwebd.exe requires native Windows. Use --fetch for the " + "published release.", + ); + exit(1); } else { await _buildFromSource(projectToolDir); } @@ -101,7 +107,7 @@ Future _buildFromSource(Directory projectToolDir) async { ); final isCI = Platform.environment['CI'] == 'true'; final Process build; - if (Platform.isWindows && isCI) { + if (isCI) { build = await Process.start( "go", [ @@ -115,7 +121,7 @@ Future _buildFromSource(Directory projectToolDir) async { runInShell: true, mode: ProcessStartMode.inheritStdio, ); - } else if (Platform.isWindows) { + } else { build = await Process.start( "wsl", [ @@ -128,25 +134,6 @@ Future _buildFromSource(Directory projectToolDir) async { runInShell: true, mode: ProcessStartMode.inheritStdio, ); - } else { - build = await Process.start( - "go", - [ - "build", - "-v", - "-o", - "../mwebd.exe", - "github.com/ltcmweb/mwebd/cmd/mwebd", - ], - environment: { - "GOOS": "windows", - "GOARCH": "amd64", - "CGO_ENABLED": "1", - "CC": "x86_64-w64-mingw32-gcc", - }, - runInShell: true, - mode: ProcessStartMode.inheritStdio, - ); } await _waitForProcess(build); @@ -161,21 +148,14 @@ Future _buildFromSource(Directory projectToolDir) async { } // copy the build mwebd.exe to assets/windows - final copy = Platform.isWindows - ? await Process.start("cmd", [ - "/C", - "copy", - "${Directory.current.parent.path}" - "${Platform.pathSeparator}mwebd.exe", - "${winAssetsDir.path}" - "${Platform.pathSeparator}mwebd.exe", - ], mode: ProcessStartMode.inheritStdio) - : await Process.start("cp", [ - "${Directory.current.parent.path}" - "${Platform.pathSeparator}mwebd.exe", - "${winAssetsDir.path}" - "${Platform.pathSeparator}mwebd.exe", - ], mode: ProcessStartMode.inheritStdio); + final copy = await Process.start("cmd", [ + "/C", + "copy", + "${Directory.current.parent.path}" + "${Platform.pathSeparator}mwebd.exe", + "${winAssetsDir.path}" + "${Platform.pathSeparator}mwebd.exe", + ], mode: ProcessStartMode.inheritStdio); await _waitForProcess(copy); // cleanup diff --git a/tool/check_build_environment.dart b/tool/check_build_environment.dart new file mode 100644 index 0000000000..c8feeff2a9 --- /dev/null +++ b/tool/check_build_environment.dart @@ -0,0 +1,360 @@ +// Run directly with Dart before build_app.sh changes any generated files. +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; + +import 'native_build_options.dart'; +import 'native_dependency_contract.dart'; + +typedef CommandRunner = Future Function( + String executable, + List arguments, + Map environment, +); + +/// Preflight only runs read-only subprocesses and reads directories. +/// Inject these boundaries in tests rather than rewriting executable scripts. +class BuildEnvironment { + BuildEnvironment({ + required this.os, + required this.architecture, + required this.variables, + required this.run, + required this.listXcodes, + }); + + final String os; + final String architecture; + final Map variables; + final CommandRunner run; + final Future> Function() listXcodes; + + factory BuildEnvironment.current() => BuildEnvironment( + os: Platform.operatingSystem, + architecture: switch (Abi.current()) { + Abi.macosArm64 || Abi.linuxArm64 || Abi.windowsArm64 => 'arm64', + Abi.macosX64 || Abi.linuxX64 || Abi.windowsX64 => 'x64', + _ => 'unsupported', + }, + variables: Platform.environment, + run: (executable, arguments, environment) => Process.run( + executable, + arguments, + environment: environment, + includeParentEnvironment: false, + ), + listXcodes: () async => [ + await for (final entry in Directory('/Applications').list()) + if (entry.path.split('/').last.startsWith('Xcode') && + entry.path.endsWith('.app') && + await Directory(entry.path).exists()) + entry.path, + ], + ); + + Future command( + String executable, + List arguments, { + Map? environment, + }) async { + try { + return await run(executable, arguments, environment ?? variables); + } on ProcessException { + return null; + } + } +} + +const wslNixProbe = r''' +set -eu +if ! command -v nix >/dev/null 2>&1; then + for profile in "$HOME/.nix-profile/bin" /nix/var/nix/profiles/default/bin /run/current-system/sw/bin; do + if [ -x "$profile/nix" ]; then + PATH="$profile:$PATH" + export PATH + break + fi + done +fi +command -v wslpath >/dev/null 2>&1 +if [ "$1" = android ] && [ "$(uname -m)" != x86_64 ]; then + exit 78 +fi +nix --version +'''; + +/// Only Apple source builds on an Apple Silicon Mac check the pinned Xcode. +bool needsXcodeRequirement( + BuildEnvironment host, { + required String platform, + required String mode, +}) => + mode == 'source' && + {'ios', 'macos'}.contains(platform) && + host.os == 'macos' && + host.architecture == 'arm64'; + +Future> checkBuildEnvironment({ + required BuildEnvironment host, + required String platform, + required String app, + required String mode, + String? wslDistribution, + XcodeRequirement? xcodeRequirement, +}) async { + if (!{'android', 'ios', 'macos', 'linux', 'windows'}.contains(platform) || + !{'stack_wallet', 'stack_duo', 'campfire'}.contains(app) || + !{'source', 'prebuilt'}.contains(mode)) { + throw ArgumentError('Invalid platform, app or dnix build mode.'); + } + final distribution = validatedDistribution(wslDistribution); + if (needsXcodeRequirement(host, platform: platform, mode: mode) && + xcodeRequirement == null) { + throw ArgumentError( + 'Apple source builds need the native Xcode requirement.', + ); + } + if (platform == 'windows' && host.os != 'windows') { + return ['Configure Windows builds from Git Bash on Windows.']; + } + if ({'ios', 'macos'}.contains(platform) && host.os != 'macos') { + return ['Apple targets require a macOS build host.']; + } + final errors = []; + var hasRustup = false; + final home = host.variables[host.os == 'windows' ? 'USERPROFILE' : 'HOME']; + final separator = host.os == 'windows' ? r'\' : '/'; + for (final executable in [ + 'rustup', + if (home != null) '$home$separator.cargo${separator}bin${separator}rustup', + ]) { + if ((await host.command(executable, ['--version']))?.exitCode == 0) { + hasRustup = true; + break; + } + } + if (!hasRustup) { + final xelis = app == 'stack_wallet' + ? ' Stack Wallet also needs Rust 1.99.1 for Xelis.' + : ''; + errors.add( + 'Install Rustup on the Flutter host (https://rustup.rs/). ' + 'Every app needs Rust 1.90.0 for DNSSEC, including with -d.$xelis ' + 'The hooks install their pinned toolchains automatically.', + ); + } + // Stack Wallet's Windows configuration builds mwebd.exe with Go and MinGW in + // the default WSL distribution (tool/build_standalone_mwebd_windows.dart), + // unless MWEBD_FETCH=1 downloads it or CI builds it natively. + if (app == 'stack_wallet' && + platform == 'windows' && + host.variables['MWEBD_FETCH'] != '1' && + host.variables['CI'] != 'true') { + final result = await host.command( + 'wsl.exe', + [ + '--cd', + '/', + '--exec', + 'bash', + '-l', + '-c', + 'command -v go && command -v x86_64-w64-mingw32-gcc', + ], + environment: {...host.variables, 'WSL_UTF8': '1'}, + ); + if (result?.exitCode != 0) { + errors.add( + "Stack Wallet's Windows MWEB build needs Go and x86_64-w64-mingw32-gcc " + 'in the default WSL distribution. Install Go and mingw-w64 there, or ' + 'set MWEBD_FETCH=1 to download the published build.', + ); + } + } + if (mode == 'prebuilt') return errors; + + // Windows hooks run Nix in the default or requested WSL distribution. + if (host.os == 'windows') { + final result = await host.command( + 'wsl.exe', + [ + if (distribution != null) ...['--distribution', distribution], + '--cd', + '/', + '--exec', + '/bin/sh', + '-c', + wslNixProbe, + 'stack-wallet-preflight', + platform, + ], + environment: {...host.variables, 'WSL_UTF8': '1'}, + ); + if (result?.exitCode == 78) { + errors.add( + 'dnix Android source builds need an x86_64 WSL distribution. ' + 'Use build_app.sh -d for verified prebuilts on this host.', + ); + } else if (result?.exitCode != 0) { + final where = distribution == null + ? 'the default distribution' + : 'distribution "$distribution"'; + errors.add( + 'Source mode needs WSL2 with Nix in $where. ' + 'Check wsl.exe --list --verbose and install Nix there, or rerun ' + 'build_app.sh with -d for verified dnix prebuilts.', + ); + } + } else if (host.os == 'macos' || host.os == 'linux') { + if (host.os == 'macos' && host.architecture != 'arm64') { + errors.add( + 'dnix source builds require an Apple Silicon Mac. ' + 'Rerun build_app.sh with -d for verified prebuilts.', + ); + } + if ((await host.command('nix', ['--version']))?.exitCode != 0) { + errors.add( + 'Source mode needs Nix on PATH (https://nixos.org/download/). ' + 'Install Nix or rerun build_app.sh with -d ' + 'for verified dnix prebuilts.', + ); + } + if (host.os == 'linux' && + platform == 'android' && + host.architecture != 'x64') { + errors.add( + 'dnix Android source builds require an x86_64 Linux host. ' + 'Use build_app.sh -d for verified prebuilts on this host.', + ); + } + } else { + errors.add( + 'dnix source builds need Linux, Apple Silicon macOS or Windows ' + 'with WSL2. Use build_app.sh -d for verified prebuilts.', + ); + } + + if (platform == 'ios' || platform == 'macos') { + if (host.architecture == 'arm64') { + final requirement = xcodeRequirement!; + final selected = await selectedDnixXcode( + host, + version: requirement.version, + pinnedBuild: requirement.upstreamBuildPinned ? requirement.build : null, + ); + if (selected == null) { + errors.add( + 'Source mode needs Xcode ${requirement.version} ' + '(${requirement.build}) under ' + '/Applications. Run dart tool/setup_xcode.dart, or use ' + 'build_app.sh -d for verified dnix prebuilts.', + ); + } else if (selected.build != requirement.build) { + errors.add( + 'dnix would select Xcode ${requirement.version} (${selected.build}) ' + 'at ${selected.directory}, rather than release ${requirement.build}. ' + 'DEVELOPER_DIR is not forwarded to native hooks. Use -d for verified ' + 'prebuilts, or keep the conflicting beta outside ' + '/Applications/Xcode*.app and make sure the system Xcode selection ' + 'does not point to it. See docs/building.md; this check changes ' + 'no Xcode selections.', + ); + } + } + } + return errors; +} + +/// Match dnix's actual lookup: global selection first, then the shell glob. +/// Flutter's hooks runner does not forward the caller's DEVELOPER_DIR. +Future<({String directory, String build})?> selectedDnixXcode( + BuildEnvironment host, { + required String version, + String? pinnedBuild, +}) async { + final environment = Map.of(host.variables) + ..remove('DEVELOPER_DIR'); + final selected = await host.command('/usr/bin/xcode-select', [ + '-p', + ], environment: environment); + final candidates = [ + if (selected?.exitCode == 0 && '${selected!.stdout}'.trim().isNotEmpty) + '${selected.stdout}'.trim(), + ]; + try { + final applications = await host.listXcodes() + ..sort(); + candidates.addAll(applications.map((app) => '$app/Contents/Developer')); + } on FileSystemException { + // The error below identifies the missing usable Xcode. + } + for (final candidate in candidates.toSet()) { + final result = await host.command( + '/usr/bin/xcodebuild', + ['-version'], + environment: {...environment, 'DEVELOPER_DIR': candidate}, + ); + if (result?.exitCode != 0) continue; + final lines = '${result!.stdout}'.trim().split(RegExp(r'\r?\n')); + if (lines.isEmpty || lines.first.trim() != 'Xcode $version') continue; + final build = lines.length > 1 + ? lines[1].trim().replaceFirst('Build version ', '') + : 'unknown'; + if (pinnedBuild != null && build != pinnedBuild) continue; + return (directory: candidate, build: build); + } + return null; +} + +Future main(List args) async { + if (args.length < 3 || args.length > 4) { + stderr.writeln( + 'Usage: dart tool/check_build_environment.dart ' + ' [wslDistribution]', + ); + exitCode = 64; + return; + } + try { + final host = BuildEnvironment.current(); + XcodeRequirement? requirement; + if (needsXcodeRequirement(host, platform: args[0], mode: args[2])) { + try { + requirement = await loadXcodeRequirement( + File.fromUri(Platform.script).parent.parent, + app: args[1], + ); + } catch (error) { + stderr.writeln('Cannot determine the required Xcode: $error'); + stderr.writeln( + error is SocketException || + error is HttpException || + error is TlsException || + error is TimeoutException + ? 'Check your network connection and retry.' + : 'See docs/building.md for dependency setup instructions.', + ); + exitCode = 1; + return; + } + } + final errors = await checkBuildEnvironment( + host: host, + platform: args[0], + app: args[1], + mode: args[2], + wslDistribution: args.length == 4 ? args[3] : null, + xcodeRequirement: requirement, + ); + if (errors.isNotEmpty) { + for (final error in errors) { + stderr.writeln('Build prerequisite: $error'); + } + stderr.writeln('See docs/building.md for setup instructions.'); + exitCode = 1; + } + } on ArgumentError catch (error) { + stderr.writeln(error.message); + exitCode = 64; + } +} diff --git a/tool/check_native_overrides.dart b/tool/check_native_overrides.dart new file mode 100644 index 0000000000..9c5a009915 --- /dev/null +++ b/tool/check_native_overrides.dart @@ -0,0 +1,30 @@ +// Plain Dart: inspect dependency checkouts without running any native hooks. +import 'dart:io'; + +import 'native_dependency_contract.dart'; + +void main(List args) { + if (args.length != 1) { + stderr.writeln('Usage: dart tool/check_native_overrides.dart '); + exitCode = 64; + return; + } + try { + final project = File.fromUri(Platform.script).parent.parent; + final overrides = nativeBuildOverrides(project, args.first); + if (overrides.isNotEmpty) { + stderr.writeln( + 'Package-local overrides take precedence over both source ' + 'and -d, and dev mode skips hash verification. Remove these files, ' + 'run flutter clean, then reconfigure:', + ); + for (final file in overrides) { + stderr.writeln(' $file'); + } + exitCode = 1; + } + } catch (error) { + stderr.writeln('Unable to check native build overrides: $error'); + exitCode = 1; + } +} diff --git a/tool/configure_native_assets.dart b/tool/configure_native_assets.dart new file mode 100644 index 0000000000..9489174b26 --- /dev/null +++ b/tool/configure_native_assets.dart @@ -0,0 +1,51 @@ +// Plain Dart so configuration does not require pub get or run native hooks. +import 'dart:convert'; +import 'dart:io'; + +import 'native_build_options.dart'; + +String configureNativeAssets( + String template, + String mode, + String? distribution, +) { + if (!['source', 'prebuilt'].contains(mode)) { + throw ArgumentError.value(mode, 'mode', 'Expected source or prebuilt'); + } + final selectedDistribution = validatedDistribution(distribution); + return template + .split('\n') + .where( + (line) => + selectedDistribution != null || + !line.contains('PLACEHOLDER_DNIX_WSL_DISTRIBUTION'), + ) + .map( + (line) => line + .replaceAll('PLACEHOLDER_DNIX_BUILD_MODE', mode) + .replaceAll( + 'PLACEHOLDER_DNIX_WSL_DISTRIBUTION', + jsonEncode(selectedDistribution), + ), + ) + .join('\n'); +} + +void main(List args) { + if (args.length < 2 || args.length > 3) { + stderr.writeln( + 'Usage: dart tool/configure_native_assets.dart ' + ' [wsl-distribution]', + ); + exitCode = 64; + return; + } + final file = File(args[0]); + file.writeAsStringSync( + configureNativeAssets( + file.readAsStringSync(), + args[1], + args.length == 3 ? args[2] : null, + ), + ); +} diff --git a/tool/native_build_options.dart b/tool/native_build_options.dart new file mode 100644 index 0000000000..736ed8716b --- /dev/null +++ b/tool/native_build_options.dart @@ -0,0 +1,14 @@ +// SDK-only options shared by configuration and its preflight. +String? validatedDistribution(String? value) { + if (value == null) return null; + final distribution = value.trim(); + if (distribution.isEmpty || + distribution.startsWith('-') || + value.contains(RegExp(r'[\x00-\x1f\x7f]'))) { + throw ArgumentError( + 'WSL distribution must be a non-empty name, without ' + 'a leading hyphen or ASCII control characters.', + ); + } + return distribution; +} diff --git a/tool/native_dependency_compatibility.json b/tool/native_dependency_compatibility.json new file mode 100644 index 0000000000..7fab350598 --- /dev/null +++ b/tool/native_dependency_compatibility.json @@ -0,0 +1,9 @@ +{ + "_comment": "Temporary Xcode release-build compatibility for exact package commits that pin only the version. Prefer toolchains.xcodeBuild upstream. New commits must be reviewed; never copy these records automatically.", + "xcodeBuilds": { + "f7fea2a926ea88e2eb6a353579372188d6915314": { "version": "27.0", "build": "27A266a" }, + "6e954a9d5a0aaaafb861049b79041d7ae98c2a08": { "version": "27.0", "build": "27A266a" }, + "63e93b01c3c4c68a52b26a43e4442a4738d5e7e8": { "version": "27.0", "build": "27A266a" }, + "5722fb3ba05c8f52e8e0255c485ae5ea52e9a694": { "version": "27.0", "build": "27A266a" } + } +} diff --git a/tool/native_dependency_contract.dart b/tool/native_dependency_contract.dart new file mode 100644 index 0000000000..00f4979182 --- /dev/null +++ b/tool/native_dependency_contract.dart @@ -0,0 +1,219 @@ +// SDK-only: setup/preflight must work before pub get, without native hooks. +import 'dart:convert'; +import 'dart:io'; + +const dnixPackageNames = { + 'frostdart', + 'tor_ffi_plugin', + 'flutter_libepiccash', + 'flutter_libmwc', +}; + +Set enabledDnixPackages(String app) => switch (app) { + 'stack_wallet' => dnixPackageNames, + 'stack_duo' => {'frostdart', 'tor_ffi_plugin'}, + 'campfire' => {'tor_ffi_plugin'}, + _ => throw ArgumentError.value(app, 'app', 'Unknown app'), +}; + +class NativeDependency { + const NativeDependency(this.name, this.repository, this.revision); + final String name; + final String repository; + final String revision; + + Directory cachedDirectory(Directory cache) => + Directory.fromUri(cache.uri.resolve('git/$repository-$revision/')); +} + +/// Read just the fixed Git dependency blocks, not arbitrary YAML. Fail closed +/// if their structure changes; this script cannot depend on package:yaml yet. +List readNativeDependencies(Directory project) { + final template = File.fromUri( + project.uri.resolve('scripts/app_config/templates/pubspec.template.yaml'), + ).readAsStringSync().replaceAll(RegExp(r'^#', multiLine: true), ''); + return [for (final name in dnixPackageNames) _readDependency(template, name)]; +} + +NativeDependency _readDependency(String template, String name) { + final block = RegExp( + '^ $name:\\r?\\n git:\\r?\\n' + r' url: https://github\.com/cypherstack/([\w-]+?)(?:\.git)?\r?\n' + r' ref: ([a-f0-9]{40})(?:\s*#[^\r\n]*)?\r?$', + multiLine: true, + ).firstMatch(template); + if (block == null) { + throw FormatException( + 'Expected a full GitHub commit pin for $name in ' + 'pubspec.template.yaml. Update the native dependency reader if the ' + 'template layout changes.', + ); + } + return NativeDependency(name, block[1]!, block[2]!); +} + +Directory? pubCacheDirectory(Map environment, String os) { + final explicit = environment['PUB_CACHE']; + if (explicit != null && explicit.isNotEmpty) return Directory(explicit); + if (os == 'windows') { + final local = environment['LOCALAPPDATA']; + return local == null ? null : Directory('$local/Pub/Cache'); + } + final home = environment['HOME']; + return home == null ? null : Directory('$home/.pub-cache'); +} + +Map resolvedNativeDirectories(Directory project) { + final config = File.fromUri( + project.uri.resolve('.dart_tool/package_config.json'), + ); + if (!config.existsSync()) return {}; + final data = jsonDecode(config.readAsStringSync()) as Map; + return { + for (final entry in data['packages'] as List) + if (dnixPackageNames.contains((entry as Map)['name'])) + entry['name'] as String: Directory.fromUri( + config.uri.resolve(entry['rootUri'] as String), + ), + }; +} + +/// Check both the selected Git pins and resolved/local overrides. A previous +/// app configuration can have a different dependency closure, so filter by app. +List nativeBuildOverrides( + Directory project, + String app, { + Map? environment, + String? os, +}) { + final enabled = enabledDnixPackages(app); + final cache = pubCacheDirectory( + environment ?? Platform.environment, + os ?? Platform.operatingSystem, + ); + final directories = { + for (final entry in resolvedNativeDirectories(project).entries) + if (enabled.contains(entry.key)) entry.value.path, + if (cache != null) + for (final dependency in readNativeDependencies(project)) + if (enabled.contains(dependency.name)) + dependency.cachedDirectory(cache).path, + }; + final overrides = {}; + for (final directory in directories) { + final file = File.fromUri(Directory(directory).uri.resolve('.dnix-build')); + if (file.existsSync()) overrides.add(file.path); + } + return overrides.toList(); +} + +class XcodeRequirement { + const XcodeRequirement({ + required this.version, + required this.build, + required this.upstreamBuildPinned, + }); + final String version; + final String build; + final bool upstreamBuildPinned; +} + +typedef NativeMetadataLoader = Future> Function( + NativeDependency dependency, +); + +Future loadXcodeRequirement( + Directory project, { + String app = 'stack_wallet', + NativeMetadataLoader? readMetadata, +}) async { + final enabled = enabledDnixPackages(app); + final compatibility = jsonDecode( + File.fromUri( + project.uri.resolve('tool/native_dependency_compatibility.json'), + ).readAsStringSync(), + ) as Map; + final legacyBuilds = compatibility['xcodeBuilds'] as Map; + final cache = pubCacheDirectory( + Platform.environment, + Platform.operatingSystem, + ); + XcodeRequirement? required; + var allPinned = true; + for (final dependency in readNativeDependencies(project)) { + if (!enabled.contains(dependency.name)) continue; + final metadata = + await (readMetadata?.call(dependency) ?? + _readMetadata(dependency, cache)); + final toolchains = metadata['toolchains'] as Map; + final version = toolchains['xcode'] as String?; + final pinnedBuild = toolchains['xcodeBuild'] as String?; + final legacy = legacyBuilds[dependency.revision] as Map?; + final build = + pinnedBuild ?? + (legacy?['version'] == version ? (legacy?['build'] as String?) : null); + if (version == null || + !RegExp(r'^\d+\.\d+(?:\.\d+)?$').hasMatch(version) || + build == null || + !RegExp(r'^[0-9A-Za-z]+$').hasMatch(build)) { + throw FormatException( + '${dependency.name}@${dependency.revision} needs ' + 'an Xcode version and release build. Prefer an upstream ' + 'toolchains.xcodeBuild pin; otherwise review and record compatibility ' + 'for this exact revision in tool/native_dependency_compatibility.json.', + ); + } + if (required != null && + (required.version != version || required.build != build)) { + throw const FormatException( + 'The native packages require different Xcode ' + 'versions/builds. Align their pins before configuring Apple builds.', + ); + } + allPinned = allPinned && pinnedBuild != null; + required = XcodeRequirement( + version: version, + build: build, + upstreamBuildPinned: allPinned, + ); + } + return required!; +} + +Future> _readMetadata( + NativeDependency dependency, + Directory? cache, +) async { + if (cache != null) { + final file = File.fromUri( + dependency.cachedDirectory(cache).uri.resolve('dnix.json'), + ); + if (file.existsSync()) { + return jsonDecode(file.readAsStringSync()) as Map; + } + } + // A fresh checkout may not have run pub get. Read only the small metadata + // file at its exact commit; do not resolve dependencies or launch hooks. + final uri = Uri.https( + 'raw.githubusercontent.com', + '/cypherstack/${dependency.repository}/${dependency.revision}/dnix.json', + ); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 20); + try { + return await (() async { + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException( + 'Unable to read pinned dnix metadata ' + '(HTTP ${response.statusCode}).', + uri: uri, + ); + } + return jsonDecode(await response.transform(utf8.decoder).join()) + as Map; + })().timeout(const Duration(seconds: 30)); + } finally { + client.close(force: true); + } +} diff --git a/tool/setup_xcode.dart b/tool/setup_xcode.dart new file mode 100644 index 0000000000..510d581469 --- /dev/null +++ b/tool/setup_xcode.dart @@ -0,0 +1,348 @@ +// Run directly with `dart tool/setup_xcode.dart`; no pub get/build hooks needed. +import 'dart:io'; + +import 'native_dependency_contract.dart'; + +// Explicit native tools let tests substitute fixtures without rewriting source +// or risking a real sudo invocation. The CLI always uses these system defaults. +class SetupTools { + const SetupTools({ + this.applications = '/Applications', + this.id = '/usr/bin/id', + this.uname = '/usr/bin/uname', + this.plistBuddy = '/usr/libexec/PlistBuddy', + this.sudo = '/usr/bin/sudo', + }); + final String applications; + final String id; + final String uname; + final String plistBuddy; + final String sudo; +} + +String shellQuote(String value) => "'${value.replaceAll("'", "'\\''")}'"; + +String installerScript({ + required String home, + required String user, + required String version, + required String build, + String? xcodes, + SetupTools tools = const SetupTools(), + bool interactive = true, + String? cleanupDirectory, +}) { + final xcodesDirectory = xcodes == null ? null : File(xcodes).parent.path; + final installerPath = [ + if (xcodesDirectory != null) xcodesDirectory, + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ].join(':'); + return '''#!/bin/bash +set -euo pipefail +umask 077 +export HOME=${shellQuote(home)} USER=${shellQuote(user)} LOGNAME=${shellQuote(user)} +export PATH=${shellQuote(installerPath)} TERM=xterm-256color +XCODE_VERSION=${shellQuote(version)} +XCODE_BUILD=${shellQuote(build)} +APPLICATIONS=${shellQuote(tools.applications)} +ID=${shellQuote(tools.id)} +UNAME=${shellQuote(tools.uname)} +PLIST_BUDDY=${shellQuote(tools.plistBuddy)} +SUDO=${shellQuote(tools.sudo)} +XCODES=${shellQuote(xcodes ?? '')} +INTERACTIVE=${interactive ? 1 : 0} +CLEANUP_DIRECTORY=${shellQuote(cleanupDirectory ?? '')} +''' + r''' +finish() { + local status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + printf '\nXcode setup failed (exit %s). Review the error above.\n' "$status" >&2 + fi + if [ "$INTERACTIVE" = 1 ] && [ -t 0 ]; then + # Keep results visible even with Terminal's close-on-exit profiles. + printf '\nPress Return to close this setup session. ' + IFS= read -r acknowledgement || true + fi + if [ -n "$CLEANUP_DIRECTORY" ]; then + /bin/rm -f "$CLEANUP_DIRECTORY/setup.sh" + /bin/rmdir "$CLEANUP_DIRECTORY" 2>/dev/null || true + fi + exit "$status" +} +trap finish EXIT +cd "$HOME" +if [ "$("$ID" -u)" = 0 ]; then + echo 'Run this helper as your normal macOS user, without sudo.' >&2 + exit 1 +fi +if [ "$("$UNAME" -m)" != arm64 ]; then + echo 'This setup helper requires an Apple Silicon Mac.' >&2 + exit 1 +fi +if [ "$INTERACTIVE" = 1 ] && [ ! -t 0 ]; then + echo 'Run this setup in its own interactive Terminal window.' >&2 + exit 1 +fi + +find_xcode() { + local candidate version build + CONFLICTING_XCODES=() + for candidate in "$APPLICATIONS"/Xcode*.app; do + [ -f "$candidate/Contents/Info.plist" ] || continue + version=$("$PLIST_BUDDY" -c 'Print :CFBundleShortVersionString' \ + "$candidate/Contents/Info.plist" 2>/dev/null) || continue + [ "$version" = "$XCODE_VERSION" ] || continue + build=$("$PLIST_BUDDY" -c 'Print :ProductBuildVersion' \ + "$candidate/Contents/version.plist" 2>/dev/null) || build=unknown + if [ "$build" = "$XCODE_BUILD" ]; then + XCODE_APP="$candidate" + return 0 + fi + CONFLICTING_XCODES+=("$candidate ($build)") + done + return 1 +} + +if ! find_xcode; then + if [ "${#CONFLICTING_XCODES[@]}" -gt 0 ]; then + printf 'These Xcode %s installations have a different build:\n' "$XCODE_VERSION" >&2 + printf ' %s\n' "${CONFLICTING_XCODES[@]}" >&2 + echo 'xcodes may refuse to install another build of the same version.' >&2 + printf 'Download Xcode %s (%s) from https://developer.apple.com/download/all/?q=Xcode\n' \ + "$XCODE_VERSION" "$XCODE_BUILD" >&2 + echo 'Expand the archive in Finder, then rename the extracted app to' >&2 + printf 'Xcode-%s-%s.app before moving it into %s.\n' \ + "$XCODE_VERSION" "$XCODE_BUILD" "$APPLICATIONS" >&2 + echo 'Rerun this helper afterward. Existing Xcodes have been left untouched.' >&2 + exit 1 + fi + if [ -z "$XCODES" ] || [ ! -x "$XCODES" ]; then + echo 'xcodes was not found on your PATH when setup was launched.' >&2 + echo 'Install it or add it to PATH, then rerun this helper.' >&2 + echo 'Homebrew option: brew install xcodes' >&2 + exit 1 + fi + if ! help=$("$XCODES" install --help); then + echo 'xcodes could not run from HOME with a clean setup environment.' >&2 + echo 'Use a globally configured xcodes or put its direct executable on PATH.' >&2 + exit 1 + fi + if [[ "$help" != *--no-superuser* ]]; then + echo 'Update xcodes: this helper requires --no-superuser support.' >&2 + exit 1 + fi + # xcodes handles Apple account authentication, never sudo's password. + "$XCODES" install "$XCODE_VERSION" --directory "$APPLICATIONS" --no-superuser --update + if ! find_xcode; then + printf 'Installation did not produce Xcode %s (%s) under %s.\n' \ + "$XCODE_VERSION" "$XCODE_BUILD" "$APPLICATIONS" >&2 + exit 1 + fi +fi + +export DEVELOPER_DIR="$XCODE_APP/Contents/Developer" +xcodebuild="$DEVELOPER_DIR/usr/bin/xcodebuild" +if [ ! -x "$xcodebuild" ]; then + echo "Missing xcodebuild in $XCODE_APP" >&2 + exit 1 +fi +version=$("$xcodebuild" -version) +if [ "$version" != "$(printf 'Xcode %s\nBuild version %s' "$XCODE_VERSION" "$XCODE_BUILD")" ]; then + printf 'Unexpected Xcode version:\n%s\n' "$version" >&2 + exit 1 +fi +printf 'Setting up %s\n' "$XCODE_APP" +echo 'Apple first-launch setup installs components and accepts the Xcode license.' +echo 'Enter your administrator password only at the sudo prompt below.' +# -k WITH a command bypasses and does not update sudo's credential cache. +# Absolute executable, no root shell, no -S, no sudo -v, no cached ticket. +"$SUDO" -k "$xcodebuild" -runFirstLaunch + +"$xcodebuild" -checkFirstLaunchStatus +printf '\n%s\n' "$version" +echo 'Setup complete. The system-wide Xcode selection is unchanged.' +echo 'For Flutter and Xcode commands in a bash/zsh build terminal, use:' +printf 'export DEVELOPER_DIR=%q\n' "$DEVELOPER_DIR" +echo 'Dart native hooks do not inherit this variable; dnix discovers Xcode separately.' +'''; +} + +String? findXcodes(String? searchPath) { + for (final directory in (searchPath ?? '').split(':')) { + // Do not execute a repo-local program via a relative/empty PATH entry. + if (!directory.startsWith('/')) continue; + final candidate = File('$directory/xcodes'); + try { + final stat = candidate.statSync(); + if (stat.type == FileSystemEntityType.file && stat.mode & 0x49 != 0) { + // Keep the name used to invoke it: mise/asdf-style shims can dispatch + // through argv[0], and their own directory must remain on PATH. + return candidate.path; + } + } on FileSystemException { + // Keep searching if an earlier PATH entry is inaccessible. + } + } + return null; +} + +String terminalCommand(String scriptPath) { + // This is typed into the user's login shell, possibly tcsh. Send only a + // short, whitespace-free command; the multiline installer stays in a file. + if (!RegExp(r'^/tmp/stack-xcode-setup-[a-zA-Z0-9]+/setup\.sh$') + .hasMatch(scriptPath)) { + throw ArgumentError.value( + scriptPath, + 'scriptPath', + 'Unexpected setup path', + ); + } + return 'exec /usr/bin/env -i /bin/bash --noprofile --norc $scriptPath'; +} + +String terminalAppleScript(String command) { + if (!RegExp(r'^[a-zA-Z0-9 /_.-]+$').hasMatch(command)) { + throw ArgumentError.value( + command, + 'command', + 'Unexpected Terminal command', + ); + } + // launch sends no run event (and hence no default startup window). Create + // the dedicated setup window before bringing Terminal to the foreground. + return 'tell application "Terminal"\n' + ' launch\n' + ' do script "$command"\n' + ' activate\n' + 'end tell\n'; +} + +Future main(List args) async { + if (args.length == 1 && ['-h', '--help'].contains(args.single)) { + stdout.writeln('''Usage: dart tool/setup_xcode.dart [--print] +Install/setup the Xcode required by the pinned native dependencies. +Finds xcodes on your current PATH before cleaning the installer environment. +Apple account prompts go to xcodes; the administrator prompt goes to sudo. +Apple first-launch setup accepts the Xcode license. No build is launched. +--print prints the installer script for review without executing it.'''); + return; + } + if (args.isNotEmpty && !(args.length == 1 && args.single == '--print')) { + stderr.writeln('Usage: dart tool/setup_xcode.dart [--print]'); + exitCode = 64; + return; + } + final home = Platform.environment['HOME']; + final user = Platform.environment['USER']; + if (home == null || !home.startsWith('/') || user == null || user.isEmpty) { + stderr.writeln('HOME and USER must identify your macOS account.'); + exitCode = 1; + return; + } + final printOnly = args.isNotEmpty; + if (!printOnly && !Platform.isMacOS) { + stderr.writeln('Xcode setup requires macOS.'); + exitCode = 1; + return; + } + if (!printOnly) { + final uid = await Process.run('/usr/bin/id', [ + '-u', + ], includeParentEnvironment: false); + if (uid.exitCode != 0 || uid.stdout.toString().trim() == '0') { + stderr.writeln( + 'Run this helper as your normal macOS user, without sudo.', + ); + exitCode = 1; + return; + } + } + final XcodeRequirement requirement; + try { + requirement = await loadXcodeRequirement( + File.fromUri(Platform.script).parent.parent, + ); + } catch (error) { + stderr.writeln('Cannot determine the required Xcode: $error'); + exitCode = 1; + return; + } + final xcodes = findXcodes(Platform.environment['PATH']); + if (printOnly) { + stdout.writeln( + installerScript( + home: home, + user: user, + version: requirement.version, + build: requirement.build, + xcodes: xcodes, + ), + ); + return; + } + if (!requirement.upstreamBuildPinned) { + stdout.writeln( + 'The native packages pin Xcode ${requirement.version} without a build ' + 'number. Using the recorded compatible release ${requirement.build} ' + 'for these exact dependency revisions.', + ); + } + // Fixed safe pathname for every supported login shell. mkdtemp creates the + // directory with mode 0700; the script contains no credentials or build env. + Directory? directory; + var launched = false; + try { + directory = Directory('/tmp').createTempSync('stack-xcode-setup-'); + final script = File('${directory.path}/setup.sh'); + script.writeAsStringSync( + installerScript( + home: home, + user: user, + version: requirement.version, + build: requirement.build, + xcodes: xcodes, + cleanupDirectory: directory.path, + ), + ); + final launch = await Process.run('/usr/bin/osascript', [ + '-e', + terminalAppleScript(terminalCommand(script.path)), + ], includeParentEnvironment: false); + if (launch.exitCode != 0) { + stderr.writeln('Could not open Terminal: ${launch.stderr}'); + exitCode = launch.exitCode; + return; + } + launched = true; + } on FileSystemException catch (error) { + stderr.writeln('Could not prepare Xcode setup: ${error.message}'); + exitCode = 1; + return; + } on ProcessException catch (error) { + stderr.writeln('Could not open Terminal: ${error.message}'); + exitCode = 1; + return; + } finally { + // Once Terminal accepts the launch, the installer owns its temporary files. + if (!launched && directory != null && directory.existsSync()) { + try { + directory.deleteSync(recursive: true); + } on FileSystemException { + stderr.writeln('Remove the unused setup directory: ${directory.path}'); + } + } + } + stdout.writeln( + 'Setup launched in a separate Terminal window. Finish the prompts there ' + 'and wait for "Setup complete" before building. Press Return there after ' + 'copying the instructions. This launcher does not receive passwords or ' + 'report installation success.', + ); +}