diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a2695a91ba..a9370c65e1 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: @@ -143,7 +146,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 +236,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' @@ -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: @@ -539,7 +560,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: @@ -723,7 +746,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 +836,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 +853,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 +868,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 +924,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 +938,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 +947,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 +995,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 +1009,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 +1018,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 +1200,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 +1290,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 +1307,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 +1322,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 +1378,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 +1392,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 +1401,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 +1449,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 +1463,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 +1472,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..9cd28caeb1 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,7 @@ 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. - name: Configure app (prebuilt native assets) run: | cd scripts @@ -56,7 +66,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 +107,19 @@ 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 + dart scripts/tests/windows_native_overrides_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..8d6246f7f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,16 +26,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 +90,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/Windows 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 +111,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 +175,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 +196,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 +222,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..3ecebb9dec 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,193 +1,381 @@ # 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. + +### Which tools are still required? + +| Component | Requirements | +|---|---| +| The four dnix packages, source mode | [Nix](https://nixos.org/download/); on Windows, Nix inside WSL2; for Apple targets, Xcode **27.0**, 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 | System libraries with `-s`, or 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, as does an optional Isar source build +(`-f`). These hooks install their pinned toolchains automatically. 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. +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. -### 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. +For Ubuntu 24.04 desktop builds, install: -### 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 +```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 ``` -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` +Use `-s` to link the system secure-storage libraries, as CI does. If you leave +out `-s`, `build_app.sh` builds JsonCPP and libsecret and also needs: -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. +```sh +sudo apt-get install meson libgirepository1.0-dev valac xsltproc \ + docbook-xsl libgcrypt20-dev libglib2.0-dev +``` -Make a Pixel 4 (API 30) x86_64 emulator with 2GB of storage space for emulation. +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. -The following *may* be needed for Android studio: -``` -sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386 -``` +### Android -### 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 -``` +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. -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 -``` +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. -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 -``` +## Mac host -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 -``` +Install Flutter 3.47.2 and Nix on an Apple Silicon Mac. **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. -### Flutter -Install Flutter 3.47.2 by [following their guide](https://docs.flutter.dev/install/manual). +### Install with xcodes -Run `flutter doctor` in a terminal to confirm its installation. +Install the [xcodes CLI](https://github.com/XcodesOrg/xcodes), then run our +standalone helper from the repository root: -### 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 -``` +```sh +brew install xcodesorg/made/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. +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.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.app/Contents/Developer/usr/bin/xcodebuild -runFirstLaunch + DEVELOPER_DIR=/Applications/Xcode-27.0.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`): -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 ../.. +```sh +export DEVELOPER_DIR=/Applications/Xcode-27.0.app/Contents/Developer +xcodebuild -version +flutter doctor ``` -### 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` +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. -or by using `scripts/linux/build_secp256k1.sh` or `scripts/windows/build_secp256k1.bat`. +**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. -### Run prebuild script +### Other Mac tools -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 .. +Install [Homebrew](https://brew.sh/) and the remaining native/plugin tools: + +```sh +brew install autoconf automake libtool cmake cocoapods pkg-config ``` -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. +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. -Options: +## Windows host - - `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. +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. + +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: -For example, -``` -./build_app.sh -a stack_wallet -p linux -v 2.1.0 -b 210 +```sh +dart scripts/windows/build_secp256k1.dart ``` -#### Building plugins for Android -> Warning: This will take a long time, please be patient -``` -cd scripts -./build_app.sh -a stack_wallet -p android -``` +Native Windows uses MSVC; Linux/WSL cross-compiles with CMake and MinGW. Both +use secp256k1 **v0.5.0** (`e3a885d42a7800c1ccebad94ad1e2b82c4df5c65`). +Their DLLs are not expected to be byte-identical across these toolchains; dnix's +hash-verification guarantee applies to its four packages, not secp256k1. The +helper reuses a DLL only when its build receipt and checksum match. The old +MXE setup and Epic-specific CMake upgrade are not needed for dnix. -#### Building plugins for Linux -``` -cd scripts -./build_app.sh -a stack_wallet -p linux -``` +If configuring from the legacy Linux/WSL shell, use a Linux Dart SDK there and +keep Rustup on the native Windows Flutter host. The prerequisite check probes +Windows Rustup through `cmd.exe` and Nix in the distribution chosen for the +native Windows hooks, so WSL interoperability must be enabled. The secp256k1 +compatibility wrapper rejects Windows Dart wrappers found on WSL's PATH. +Configuration also dispatches the override guard to Windows Dart to inspect +the Windows pub cache. To repeat that check manually from WSL, run +`dart tool/check_native_overrides.dart windows`; from native Windows, +the target argument is optional. -#### Building plugins and configure for Windows -*This step is only necessary inside WSL2 for building on a Windows host.* +## Configure and build -Install dependencies like MXE: -``` -cd scripts/windows -./deps.sh -``` +Clone the repository, then run the setup from its root: -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 +git clone https://github.com/cypherstack/stack_wallet.git +cd stack_wallet +bash scripts/prebuild.sh ``` -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. +`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`. -go version should be at least 1.24 +All four configuration arguments are required: -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 -``` - -### Running -#### Android -Plug in your android device or use the emulator available via Android Studio and then run the following commands: -``` +```sh +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s flutter pub get -flutter run android +dart tool/check_native_overrides.dart stack_wallet +flutter build linux --release ``` -Note on Emulators: Only x86_64 emulators are supported, x86 emulators will not work. You should [configure KVM](https://help.ubuntu.com/community/KVM/Installation) for much better performance. - -#### Linux -Run the following commands or launch via Android Studio: -``` -flutter pub get -flutter run linux -``` +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: -#### Linux release bundle preparation +```sh +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s -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 | +| `-s` | Use system Linux secure-storage libraries | +| `-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` | + +Configuration checks Rustup, source-mode Nix/WSL/Xcode requirements, and the +Windows secp256k1 tools 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 +388,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 +397,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 -``` - +## CI and configuration checks -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 -``` +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. -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. +After configuring an app and running `flutter pub get`, check template/mode +selection without compiling native libraries: -### 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: - -- `stack_wallet/scripts/windows/build_secp256k1_wsl.sh` - -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. - -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 cache. 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 0ef5e9204f..f8da52a83d 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..a03b977cc8 100755 --- a/scripts/app_config/shared/asset_generators.sh +++ b/scripts/app_config/shared/asset_generators.sh @@ -13,13 +13,17 @@ APP_BUILD_PLATFORM=$1 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 + # Exclude only the CMD switch, preserving other conversions and user exclusions. + # Uppercase /C avoids excluding the usual lowercase /c/... drive paths. + MSYS2_ARG_CONV_EXCL="/C${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ + 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}" + MSYS2_ARG_CONV_EXCL="/C${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ + 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 @@ -30,4 +34,4 @@ else dart run flutter_native_splash:create fi 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..4b98d7449c 100755 --- a/scripts/app_config/shared/link_assets.sh +++ b/scripts/app_config/shared/link_assets.sh @@ -30,7 +30,9 @@ for dirname in "default_themes" "icon" "lottie" "in_app_logo_icons" "svg"; do 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}" + # 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/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 6c576862ec..8e648fb79c 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..54fa4335e5 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] [-s] [-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. -s: use system Linux secure-storage libraries." + echo " -w: dnix WSL distribution (default: DNIX_WSL_DISTRIBUTION or WSL default)." + exit "${1:-1}" } confirmDisclaimer() { @@ -31,24 +36,25 @@ 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) DNIX_WSL_DISTRIBUTION="$OPTARG" ;; + h) usage 0 ;; *) usage ;; esac done @@ -94,6 +100,18 @@ if [[ ! "$APP_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then fi confirmDisclaimer + +# Check requirements before replacing any generated app/platform files. +dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" "$APP_BUILD_PLATFORM" +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,11 +130,10 @@ 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 @@ -124,8 +141,11 @@ if [[ "$APP_BUILD_PLATFORM" != "linux" ]]; then "${APP_PROJECT_ROOT_DIR}/scripts/app_config/shared/asset_generators.sh" "${APP_BUILD_PLATFORM}" fi +# Asset generation can resolve dependencies; inspect the resulting checkouts too. +dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" "$APP_BUILD_PLATFORM" + # 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 +156,11 @@ 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. This separate library is skipped + # with -i, or reused when its local build receipt still matches. + pushd "${APP_PROJECT_ROOT_DIR}" + dart "${APP_PROJECT_ROOT_DIR}/scripts/windows/build_secp256k1.dart" + popd ;; 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..9ffad3ece7 --- /dev/null +++ b/scripts/tests/build_environment_test.dart @@ -0,0 +1,438 @@ +// 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 windowsRustupOnPath = true; + bool windowsRustupInHome = false; + bool windowsInterop = true; + bool nix = true; + bool wslHost = false; + int wslExit = 0; + 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', + if (wslHost) 'WSL_DISTRO_NAME': 'Current-Distribution', + }, + 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') { + return ProcessResult(0, wslExit, 'nix fixture', ''); + } + if (executable == 'cmd.exe') { + if (!windowsInterop) throw ProcessException(executable, arguments); + final available = arguments.last == 'rustup --version' + ? windowsRustupOnPath + : windowsRustupInHome; + return ProcessResult( + 0, + available ? 0 : 1, + 'Windows rustup 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'), + ); + }); + + test( + 'legacy WSL configuration probes the distribution Windows hooks use', + () async { + fixture.os = 'linux'; + fixture.wslHost = true; + fixture.nix = false; // Nix in this configuring shell is irrelevant. + fixture.rustupOnPath = false; // Its Linux Rustup is also irrelevant. + expect( + await fixture.check( + platform: 'windows', + distribution: 'Other-Distribution', + ), + isEmpty, + ); + final call = fixture.calls.last; + expect(call.executable, 'wsl.exe'); + expect(call.arguments.take(2), ['--distribution', 'Other-Distribution']); + expect(fixture.calls.any((call) => call.executable == 'nix'), isFalse); + expect(fixture.calls.first.executable, 'cmd.exe'); + expect(fixture.calls.first.arguments, [ + '/d', + '/s', + '/c', + 'rustup --version', + ]); + expect( + fixture.calls.any((call) => call.executable.endsWith('rustup')), + isFalse, + ); + }, + ); + + test( + 'Linux Rustup cannot satisfy legacy Windows Flutter requirements', + () async { + fixture.os = 'linux'; + fixture.wslHost = true; + fixture.windowsRustupOnPath = false; + final errors = await fixture.check(platform: 'windows', mode: 'prebuilt'); + expect(errors.single, contains('Install Rustup on native Windows')); + expect(errors.single, contains('including with -d')); + expect( + fixture.calls.every((call) => call.executable == 'cmd.exe'), + isTrue, + ); + }, + ); + + test('legacy WSL Rustup fallback expands the Windows user profile', () async { + fixture.os = 'linux'; + fixture.wslHost = true; + fixture.windowsRustupOnPath = false; + fixture.windowsRustupInHome = true; + expect(await fixture.check(platform: 'windows', mode: 'prebuilt'), isEmpty); + expect(fixture.calls.last.arguments, [ + '/d', + '/s', + '/c', + r'""%USERPROFILE%\.cargo\bin\rustup.exe" --version"', + ]); + expect(fixture.calls.every((call) => call.executable == 'cmd.exe'), isTrue); + }); + + test( + 'missing WSL interoperability explains native Windows configuration', + () async { + fixture.os = 'linux'; + fixture.wslHost = true; + fixture.windowsInterop = false; + expect( + (await fixture.check(platform: 'windows', mode: 'prebuilt')).single, + contains('configure from Git Bash on Windows'), + ); + }, + ); + + test('Linux Flutter in WSL still checks Linux Rustup', () async { + fixture.os = 'linux'; + fixture.wslHost = true; + fixture.windowsInterop = false; + expect(await fixture.check(platform: 'linux', mode: 'prebuilt'), isEmpty); + expect(fixture.calls.single.executable, 'rustup'); + }); + + test('Linux cross configuration outside WSL probes local Nix', () async { + fixture.os = 'linux'; + expect(await fixture.check(platform: 'windows'), isEmpty); + expect(fixture.calls.last.executable, 'nix'); + }); + + test('global GA Xcode wins over an alphabetically earlier beta', () async { + fixture.applications.insert(0, '/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.insert(0, '/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/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..e30fa5774d --- /dev/null +++ b/scripts/tests/native_build_config_test.dart @@ -0,0 +1,408 @@ +// 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'"); + // 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 != 2) 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': '', + ...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, platform]); + } + 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, 'native-cwd')).readAsStringSync(), + root.resolveSymbolicLinksSync(), + ); + 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, '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('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..848fd7c0b6 --- /dev/null +++ b/scripts/tests/native_dependency_contract_test.dart @@ -0,0 +1,255 @@ +// 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('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_native_overrides_test.dart b/scripts/tests/windows_native_overrides_test.dart new file mode 100644 index 0000000000..b5f598f095 --- /dev/null +++ b/scripts/tests/windows_native_overrides_test.dart @@ -0,0 +1,102 @@ +// Run with: dart scripts/tests/windows_native_overrides_test.dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../tool/check_native_overrides.dart'; + +String decodePowerShell(String encoded) { + final bytes = base64Decode(encoded); + return String.fromCharCodes([ + for (var i = 0; i < bytes.length; i += 2) bytes[i] | (bytes[i + 1] << 8), + ]); +} + +void main() { + test('WSL guard targets native Dart with literal Windows paths', () async { + final calls = <({String executable, List arguments})>[]; + const script = "/mnt/c/Users/O'Brien & %HOME%/工具/check.dart"; + const translated = "C:\\Users\\O'Brien & %HOME%\\工具\\check.dart"; + final result = await checkWindowsOverridesFromWsl( + script: script, + app: 'stack_wallet', + run: (executable, arguments) async { + calls.add((executable: executable, arguments: arguments)); + if (executable == 'wslpath') { + return ProcessResult(1, 0, '$translated\n', ''); + } + return ProcessResult(2, 23, 'native output', 'native failure'); + }, + ); + expect(calls.first.executable, 'wslpath'); + expect(calls.first.arguments, ['-w', script]); + expect(calls.last.executable, 'powershell.exe'); + expect(calls.last.arguments.take(3), [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + ]); + final command = decodePowerShell(calls.last.arguments.last); + expect(command, windowsOverrideCommand(translated, 'stack_wallet')); + expect( + command, + contains( + "& \$dart 'C:\\Users\\O''Brien & %HOME%\\工具\\check.dart' 'stack_wallet' windows;", + ), + ); + expect(command, contains('Get-Command dart.exe -CommandType Application')); + expect(command, contains(r"'cache\dart-sdk\bin\dart.exe'")); + expect(command, endsWith(r'exit $LASTEXITCODE')); + expect(result.exitCode, 23); + expect(result.stdout, 'native output'); + expect(result.stderr, 'native failure'); + }); + + test('path conversion failure never invokes native guard', () async { + final calls = []; + await expectLater( + checkWindowsOverridesFromWsl( + script: '/checkout/tool/check_native_overrides.dart', + app: 'campfire', + run: (executable, arguments) async { + calls.add(executable); + return ProcessResult(1, 1, '', 'invalid path'); + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('wslpath could not translate'), + ), + ), + ); + expect(calls, ['wslpath']); + }); + + test( + 'missing interoperability explains native Windows alternative', + () async { + await expectLater( + checkWindowsOverridesFromWsl( + script: '/checkout/tool/check_native_overrides.dart', + app: 'stack_duo', + run: (executable, arguments) async { + if (executable == 'wslpath') { + return ProcessResult(1, 0, r'C:\checkout\check.dart', ''); + } + throw ProcessException(executable, arguments, 'not found'); + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Enable WSL interoperability'), + ), + ), + ); + }, + ); +} diff --git a/scripts/tests/windows_secp_build_test.dart b/scripts/tests/windows_secp_build_test.dart new file mode 100644 index 0000000000..df37427819 --- /dev/null +++ b/scripts/tests/windows_secp_build_test.dart @@ -0,0 +1,421 @@ +// Run with: dart scripts/tests/windows_secp_build_test.dart +import 'dart:io'; +import 'dart:isolate'; + +import 'package:crypto/crypto.dart'; +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; + late Map compilerFlags; + String? failAt; + bool missingDll = false; + bool cleanupLocked = false; + + test('secp pin matches resolved coinlib Windows recipes', () 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 cross = File.fromUri( + library.resolve('../bin/build_windows_crosscompile.dart'), + ).readAsStringSync(); + final nativePin = RegExp( + r'''["']checkout["']\s*,\s*["']([0-9a-f]{40})["']''', + ).firstMatch(native)?.group(1); + final crossPin = RegExp(r'git\s+checkout\s+([0-9a-f]{40})\b') + .firstMatch(cross) + ?.group(1); + expect( + nativePin, + secpRevision, + reason: 'Review the resolved coinlib native Windows secp revision.', + ); + expect( + crossPin, + secpRevision, + reason: 'Review the resolved coinlib cross-build 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'; + compilerFlags = {}; + failAt = null; + missingDll = false; + cleanupLocked = false; + for (final entry in { + 'scripts/windows/build_secp256k1.dart': 'fixture recipe', + '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({bool nativeWindows = true}) => WindowsSecpBuilder( + root: root, + nativeWindows: nativeWindows, + environment: {'ProgramFiles(x86)': programFiles, ...compilerFlags}, + cleanup: (directory) { + if (cleanupLocked) { + throw FileSystemException('fixture locked directory', directory.path); + } + directory.deleteSync(recursive: true); + }, + run: (executable, args, {workingDirectory}) 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')) 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 (executable == 'certutil.exe' || executable == 'sha256sum') { + final file = File(args[executable == 'certutil.exe' ? 1 : 0]); + return result('${sha256.convert(file.readAsBytesSync())}\n'); + } + if (args.length == 1 && args.single == '--version') { + if (executable == 'cmake' && nativeWindows) { + 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/${nativeWindows ? '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.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); + }); + + for (final native in [true, false]) { + test( + 'rejects unsupported CMake on ${native ? 'Windows' : 'Linux'}', + () async { + cmakeVersion = native ? 'cmake version 3.20.6' : 'cmake version 3.12.4'; + await expectLater( + builder(nativeWindows: native).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, + ]); + expect( + operation('checkout').single.cwd, + startsWith('${root.path}/build/'), + ); + 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( + File('${root.path}/build/secp256k1.dll.json').existsSync(), + isTrue, + ); + }, + ); + + test('WSL uses the same revision, with its MinGW toolchain', () async { + await builder(nativeWindows: false).build(); + expect(operation('checkout').single.args.last, secpRevision); + expect(operation('-S').single.args, contains('Unix Makefiles')); + expect( + operation('-S').single.args + .any((a) => a.endsWith('/cmake/x86_64-w64-mingw32.toolchain.cmake')), + isTrue, + ); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isTrue); + expect(calls.any((c) => c.executable.contains('mingw32-g++')), isFalse); + }); + + test('matching receipt avoids fetching and recompiling', () async { + await builder().build(); + calls.clear(); + await builder().build(); + expect(operation('fetch'), isEmpty); + expect(operation('--build'), isEmpty); + }); + + test('modified DLL invalidates the cache', () async { + await builder().build(); + File('${root.path}/build/secp256k1.dll').writeAsStringSync('corrupted'); + calls.clear(); + await builder().build(); + expect(operation('fetch'), hasLength(1)); + expect( + File('${root.path}/build/secp256k1.dll').readAsStringSync(), + 'fixture Windows library', + ); + }); + + test('changed compiler host or recipe invalidates the cache', () async { + await builder().build(); + calls.clear(); + await builder(nativeWindows: false).build(); + expect(operation('fetch'), hasLength(1)); + File('${root.path}/scripts/windows/build_secp256k1.dart') + .writeAsStringSync('changed recipe'); + calls.clear(); + await builder(nativeWindows: false).build(); + expect(operation('fetch'), hasLength(1)); + }); + + test('changed CMake identity invalidates the cache', () async { + await builder().build(); + cmakeVersion = 'cmake version 4.1.0'; + calls.clear(); + await builder().build(); + expect(operation('fetch'), hasLength(1)); + }); + + test('new SDK version invalidates cache and is selected for CMake', () async { + await builder().build(); + installSdk('10.0.26100.0'); + calls.clear(); + await builder().build(); + expect(operation('fetch'), hasLength(1)); + expect( + operation('-S').single.args, + contains('-DCMAKE_SYSTEM_VERSION=10.0.26100.0'), + ); + }); + + test('changed explicit compiler flags invalidate the cache', () async { + await builder().build(); + compilerFlags = {'CL': '/O1', 'LDFLAGS': '-s'}; + calls.clear(); + await builder().build(); + expect(operation('fetch'), hasLength(1)); + }); + + for (final operation in ['init', 'fetch', 'checkout', '-S', '--build']) { + test( + '$operation failure propagates without publishing a receipt', + () async { + failAt = operation; + await expectLater( + builder().build(), + throwsA(isA()), + ); + expect( + File('${root.path}/build/secp256k1.dll.json').existsSync(), + isFalse, + ); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isFalse); + }, + ); + } + + test('a successful CMake exit without the DLL is rejected', () async { + missingDll = true; + await expectLater(builder().build(), throwsA(isA())); + expect(File('${root.path}/build/secp256k1.dll.json').existsSync(), isFalse); + }); + + test('locked cleanup leaves a successfully published DLL usable', () async { + cleanupLocked = true; + await builder().build(); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isTrue); + expect(File('${root.path}/build/secp256k1.dll.json').existsSync(), isTrue); + calls.clear(); + await builder().build(); + expect(operation('fetch'), isEmpty); + }); + + 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.json').existsSync(), isFalse); + }); + + group('WSL entry point', () { + late Directory bin; + late File shim; + late File recorded; + setUp(() { + bin = Directory('${root.path}/bin')..createSync(); + final repository = File.fromUri(Platform.script).parent.parent.parent; + shim = File('${root.path}/build_secp256k1_wsl.sh'); + File('${repository.path}/scripts/windows/build_secp256k1_wsl.sh') + .copySync(shim.path); + recorded = File('${root.path}/dart-arguments'); + final fakeDart = File('${bin.path}/dart') + ..writeAsStringSync(r'''#!/bin/sh +if [ "$1" = --version ]; then + printf 'Dart SDK version: fixture on "%s"\n' "$FIXTURE_DART_HOST" >&2 + exit 0 +fi +printf '%s\n' "$@" > "$FIXTURE_ARGUMENTS" +exit 37 +'''); + expect(Process.runSync('chmod', ['+x', fakeDart.path]).exitCode, 0); + }); + Future invoke(String host) => Process.run( + '/bin/bash', + [shim.path, '--check'], + environment: { + 'PATH': '${bin.path}:/usr/bin:/bin', + 'FIXTURE_DART_HOST': host, + 'FIXTURE_ARGUMENTS': recorded.path, + }, + ); + + test('Linux SDK runs the helper and propagates its status', () async { + final result = await invoke('linux_x64'); + expect(result.exitCode, 37); + expect(recorded.readAsLinesSync(), [ + '${root.path}/build_secp256k1.dart', + '--check', + ]); + }); + + test( + 'Windows Flutter on WSL PATH fails before invoking the helper', + () async { + final result = await invoke('windows_x64'); + expect(result.exitCode, 1); + expect(result.stderr, contains("Linux SDK's bin directory")); + expect(recorded.existsSync(), isFalse); + }, + ); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); +} diff --git a/scripts/tests/xcode_setup_test.dart b/scripts/tests/xcode_setup_test.dart new file mode 100644 index 0000000000..c08bcc0347 --- /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 index b619e6e78e..502c7d0e26 100644 --- a/scripts/windows/build_secp256k1.bat +++ b/scripts/windows/build_secp256k1.bat @@ -1,13 +1,4 @@ -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 ..\..\..\ +@echo off +rem Compatibility entry point; the Dart helper discovers VS CMake and checks exits. +dart "%~dp0build_secp256k1.dart" %* +exit /b %errorlevel% diff --git a/scripts/windows/build_secp256k1.dart b/scripts/windows/build_secp256k1.dart new file mode 100644 index 0000000000..5f2f0ac859 --- /dev/null +++ b/scripts/windows/build_secp256k1.dart @@ -0,0 +1,388 @@ +// 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, +}); + +Future _run( + String executable, + List arguments, { + String? workingDirectory, +}) => Process.run(executable, arguments, workingDirectory: workingDirectory); + +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.nativeWindows, + required this.environment, + this.run = _run, + this.cleanup = _removeWork, + }); + + final Directory root; + final bool nativeWindows; + final Map environment; + final CommandRunner run; + final void Function(Directory) cleanup; + late String cmake; + String? visualStudio; + ({String root, String version})? windowsSdk; + late Map identity; + + 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<({String root, String version})> findWindowsSdk() 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 = <({String root, String version})>[]; + 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((root: sdkRoot, version: 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.version, a.version)); + return usable.first; + } + + Future checked( + String executable, + List arguments, { + String? workingDirectory, + }) async { + final ProcessResult result; + try { + result = await run( + executable, + arguments, + workingDirectory: workingDirectory, + ); + } 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; + } + + Future hash(File file) async { + final result = await checked( + nativeWindows ? 'certutil.exe' : 'sha256sum', + nativeWindows ? ['-hashfile', file.path, 'SHA256'] : [file.path], + ); + final match = RegExp( + r'^[a-fA-F0-9]{64}(?=\s|$)', + multiLine: true, + ).firstMatch(result.stdout.toString()); + if (match == null) { + throw SecpBuildException('Could not read SHA-256 for ${file.path}.'); + } + return match.group(0)!.toLowerCase(); + } + + // No checkout, output creation, or compilation: safe before app config. + Future check() async { + await checked('git', ['--version']); + String compiler; + if (nativeWindows) { + 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).', + ); + } + final result = await checked(vswhere, [ + '-latest', + '-products', + '*', + '-version', + '[17.0,18.0)', + '-requires', + 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', + 'installationPath', + ]); + 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.', + ); + } + compiler = '${visualStudio!}\n${toolsVersion.readAsStringSync().trim()}'; + windowsSdk = await findWindowsSdk(); + } else { + compiler = (await checked('x86_64-w64-mingw32-gcc', [ + '--version', + ])).stdout.toString(); + await checked('make', ['--version']); + } + final bundledCmake = + '$visualStudio/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe'; + cmake = nativeWindows && 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); + final minimum = nativeWindows ? '3.21' : '3.13'; + 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).', + ); + } + identity = { + 'revision': secpRevision, + 'host': nativeWindows ? 'windows-msvc-x64' : 'linux-mingw-x64', + 'recipe': await hash( + File('${root.path}/scripts/windows/build_secp256k1.dart'), + ), + 'cmake': '${cmakeVersion.stdout}'.trim(), + 'compiler': compiler.trim(), + if (windowsSdk != null) + 'sdk': '${windowsSdk!.root}\n${windowsSdk!.version}', + // Only explicit compile/link flags, not unrelated process environment. + for (final flag in [ + 'CFLAGS', + 'CXXFLAGS', + 'CPPFLAGS', + 'LDFLAGS', + 'CL', + '_CL_', + 'LINK', + '_LINK_', + ]) + flag: environment[flag] ?? '', + }; + } + + Future build() async { + // --check runs before configuration in a separate process. Revalidate here + // to identify the tools and inputs actually used by this build/cache lookup. + await check(); + final output = File('${root.path}/build/secp256k1.dll'); + final receipt = File('${output.path}.json'); + if (output.existsSync() && receipt.existsSync()) { + try { + final recorded = + jsonDecode(receipt.readAsStringSync()) as Map; + if (identity.entries.every((e) => recorded[e.key] == e.value) && + recorded['sha256'] == await hash(output)) { + stdout.writeln( + 'Reusing ${output.path} (verified local build cache).', + ); + return; + } + } on FormatException { + // An incomplete/invalid receipt is a cache miss. + } on TypeError { + // An unrelated JSON value is also a cache miss. + } + } + output.parent.createSync(recursive: true); + // An interrupted rebuild must not leave a receipt claiming an old DLL + // is current. + if (receipt.existsSync()) receipt.deleteSync(); + 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, + if (nativeWindows) ...[ + '-G', + 'Visual Studio 17 2022', + '-A', + 'x64', + '-DCMAKE_GENERATOR_INSTANCE=$visualStudio', + '-DCMAKE_SYSTEM_VERSION=${windowsSdk!.version}', + ] else ...[ + '-G', + 'Unix Makefiles', + '-DCMAKE_TOOLCHAIN_FILE=$source/cmake/x86_64-w64-mingw32.toolchain.cmake', + ], + '-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/${nativeWindows ? 'RelWithDebInfo/' : ''}libsecp256k1-2.dll', + ); + if (!library.existsSync() || library.lengthSync() == 0) { + throw SecpBuildException( + 'CMake did not produce the expected DLL: ${library.path}', + ); + } + final newReceipt = File('${work.path}/receipt.json'); + newReceipt.writeAsStringSync( + '${jsonEncode({...identity, 'sha256': await hash(library)})}\n', + ); + // Both temporary files are under build/ on the destination filesystem. + // Publish complete files; a failed/interrupted publish has no valid receipt. + library.renameSync(output.path); + newReceipt.renameSync(receipt.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 && !Platform.isLinux) { + stderr.writeln('Windows secp256k1 requires native Windows or Linux/WSL.'); + exitCode = 1; + return; + } + final builder = WindowsSecpBuilder( + root: File.fromUri(Platform.script).parent.parent.parent, + nativeWindows: Platform.isWindows, + 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 old mode 100644 new mode 100755 index cedb2bc2c1..e0790f60f9 --- a/scripts/windows/build_secp256k1_wsl.sh +++ b/scripts/windows/build_secp256k1_wsl.sh @@ -1,15 +1,16 @@ -mkdir -p build -cd build -if [ ! -d "secp256k1" ]; then - git clone https://github.com/bitcoin-core/secp256k1 +#!/usr/bin/env bash +set -e +# Compatibility entry point; shares the native Windows source revision and recipe. +# WSL often inherits Windows Flutter on PATH. Refuse that SDK before handing it +# a Linux path or accidentally selecting the native Windows compiler. +if ! dart_version=$(dart --version 2>&1); then + echo "Could not run Dart. Install a Linux Dart/Flutter SDK inside WSL and put its bin directory on PATH." >&2 + exit 1 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 ../../../ +case "$dart_version" in + *'on "linux_'*) ;; + *) echo "This WSL build needs Linux Dart, but PATH selects: $dart_version" >&2 + echo "Put your Linux SDK's bin directory before Windows Flutter on WSL's PATH." >&2 + exit 1 ;; +esac +exec dart "$(dirname -- "${BASH_SOURCE[0]}")/build_secp256k1.dart" "$@" diff --git a/tool/check_build_environment.dart b/tool/check_build_environment.dart new file mode 100644 index 0000000000..873bad64dc --- /dev/null +++ b/tool/check_build_environment.dart @@ -0,0 +1,341 @@ +// Run directly with Dart before build_app.sh changes any generated files. +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 +'''; + +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); + final appleSource = mode == 'source' && {'ios', 'macos'}.contains(platform); + if (appleSource && xcodeRequirement == null) { + throw ArgumentError( + 'Apple source builds need the native Xcode requirement.', + ); + } + final errors = []; + // Legacy WSL configuration invokes native Windows Flutter later. Probe the + // Windows Rustup it will use, not the unrelated Linux Rustup in this shell. + final windowsFromWsl = + platform == 'windows' && + host.os == 'linux' && + (host.variables['WSL_DISTRO_NAME']?.isNotEmpty ?? false); + var hasRustup = false; + if (windowsFromWsl) { + for (final command in [ + 'rustup --version', + r'""%USERPROFILE%\.cargo\bin\rustup.exe" --version"', + ]) { + if ((await host.command('cmd.exe', [ + '/d', + '/s', + '/c', + command, + ]))?.exitCode == + 0) { + hasRustup = true; + break; + } + } + } else { + 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.' + : ''; + final flutterHost = windowsFromWsl ? 'native Windows' : 'the Flutter host'; + final interoperability = windowsFromWsl + ? ' WSL must be able to launch cmd.exe; configure from Git Bash on ' + 'Windows if interoperability is unavailable.' + : ''; + errors.add( + 'Install Rustup on $flutterHost (https://rustup.rs/). ' + 'Every app needs Rust 1.90.0 for DNSSEC, including with -d.$xelis ' + 'The hooks install their pinned toolchains automatically.' + '$interoperability', + ); + } + if (mode == 'prebuilt') return errors; + + // Windows hooks select the default/requested distro, which can differ from + // this shell's current WSL distribution. Probe through wsl.exe in both cases. + if (host.os == 'windows' || windowsFromWsl) { + 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.os != 'macos') { + errors.add('Apple targets require a macOS build host.'); + } else 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 { + XcodeRequirement? requirement; + if (args[2] == 'source' && {'ios', 'macos'}.contains(args[0])) { + try { + requirement = await loadXcodeRequirement( + File.fromUri(Platform.script).parent.parent, + ); + } catch (error) { + stderr.writeln('Cannot determine the required Xcode: $error'); + stderr.writeln( + 'See docs/building.md for dependency setup instructions.', + ); + exitCode = 1; + return; + } + } + final errors = await checkBuildEnvironment( + host: BuildEnvironment.current(), + 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..96597a64e0 --- /dev/null +++ b/tool/check_native_overrides.dart @@ -0,0 +1,116 @@ +// Plain Dart: inspect dependency checkouts without running any native hooks. +import 'dart:convert'; +import 'dart:io'; + +import 'native_dependency_contract.dart'; + +typedef OverrideCommandRunner = Future Function( + String executable, + List arguments, +); + +Future _run(String executable, List arguments) => + Process.run(executable, arguments); + +String windowsOverrideCommand(String script, String app) { + String literal(String value) => "'${value.replaceAll("'", "''")}'"; + // Flutter normally exposes dart.bat. Invoke its SDK executable directly so + // cmd.exe cannot expand percent signs in script paths or arguments. + return r"$ErrorActionPreference = 'Stop'; " + r'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + r'$sdk = Get-Command dart.exe -CommandType Application ' + r'-ErrorAction SilentlyContinue | Select-Object -First 1; ' + r'if ($null -ne $sdk) { $dart = $sdk.Source } else { ' + r'$launcher = Get-Command dart -CommandType Application ' + r'-ErrorAction Stop; ' + r'$dart = Join-Path ' + r'([System.IO.Path]::GetDirectoryName($launcher.Source)) ' + r"'cache\dart-sdk\bin\dart.exe'; " + r'if (!(Test-Path -LiteralPath $dart -PathType Leaf)) { ' + r"throw 'Put native Windows Flutter or a Dart SDK on the Windows PATH, " + r"or configure from Git Bash.' } }; " + '& \$dart ${literal(script)} ${literal(app)} windows; ' + r'exit $LASTEXITCODE'; +} + +String encodePowerShell(String command) => base64Encode([ + for (final unit in command.codeUnits) ...[unit & 0xff, unit >> 8], +]); + +Future checkWindowsOverridesFromWsl({ + required String script, + required String app, + OverrideCommandRunner run = _run, +}) async { + try { + final converted = await run('wslpath', ['-w', script]); + if (converted.exitCode != 0 || converted.stdout.toString().trim().isEmpty) { + throw StateError('wslpath could not translate the override guard path.'); + } + return await run('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + encodePowerShell( + windowsOverrideCommand(converted.stdout.toString().trim(), app), + ), + ]); + } on ProcessException catch (error) { + throw StateError( + 'Could not check the native Windows dependency cache from WSL: $error. ' + 'Enable WSL interoperability and put Windows Flutter on the Windows ' + 'PATH, or configure from Git Bash.', + ); + } +} + +Future main(List args) async { + if (args.isEmpty || + args.length > 2 || + (args.length == 2 && + !{'android', 'ios', 'macos', 'linux', 'windows'}.contains(args[1]))) { + stderr.writeln( + 'Usage: dart tool/check_native_overrides.dart [targetPlatform]', + ); + exitCode = 64; + return; + } + try { + enabledDnixPackages(args.first); // Validate before dispatching to Windows. + if (args.length == 2 && + args[1] == 'windows' && + Platform.isLinux && + (Platform.environment['WSL_DISTRO_NAME']?.isNotEmpty ?? false)) { + final result = await checkWindowsOverridesFromWsl( + script: File.fromUri(Platform.script).absolute.path, + app: args.first, + ); + stdout.write(result.stdout); + stderr.write(result.stderr); + if (result.exitCode != 0) { + stderr.writeln( + 'Windows override check failed. Fix the reported issue or ' + 'configure from native Windows Git Bash.', + ); + } + exitCode = result.exitCode; + return; + } + 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..2739b0cd2d --- /dev/null +++ b/tool/native_dependency_contract.dart @@ -0,0 +1,216 @@ +// 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, { + NativeMetadataLoader? readMetadata, +}) async { + 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)) { + 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}). Run flutter pub get and retry.', + 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..11293f5f75 --- /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 xcodesorg/made/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.', + ); +}