From 82d4e814fad94be9dfca50aca212d3c7c76a9534 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Mon, 9 Nov 2026 09:00:24 -0600 Subject: [PATCH 01/13] Migrate native packages to verified dnix builds Pin the merged Frost, Tor, Epic and MWC packages. Configure verified source builds by default and verified prebuilts with -d, including before asset generators can execute hooks. Handle native Windows secp256k1 builds, raise the macOS deployment target to 12, keep CI on prebuilts, remove obsolete Rust setup, and refresh build requirements. Add app/mode and host dispatch regression coverage. Validation: 39 script tests and 8 Apple Silicon native consumer smoke tests passed; all three app dependency sets resolve; generated adapters and the new tests analyze cleanly; workflow lint, formatting and shell syntax checks pass. Full app and Windows/WSL builds were not run locally. --- .github/workflows/build.yaml | 56 +- .github/workflows/test.yaml | 4 +- Dockerfile | 3 +- docs/building.md | 543 +++++++----------- pubspec.lock | 31 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- .../templates/pubspec.template.yaml | 46 +- scripts/build_app.sh | 47 +- scripts/tests/native_build_config_test.dart | 241 ++++++++ 9 files changed, 542 insertions(+), 435 deletions(-) create mode 100644 scripts/tests/native_build_config_test.dart diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a2695a91ba..f34bf63539 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -17,8 +17,8 @@ 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). +# Xelis and Spark still use their own source-build hooks. jobs: build-disclaimer: @@ -823,12 +823,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 @@ -909,12 +903,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 @@ -970,11 +958,6 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - targets: aarch64-apple-ios - - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -984,14 +967,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 @@ -1281,12 +1256,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 @@ -1367,14 +1336,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 @@ -1430,11 +1391,6 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - targets: aarch64-apple-ios - - uses: subosito/flutter-action@v2 with: flutter-version: '3.47.2' @@ -1444,14 +1400,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 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 649aaa6b43..d11ac824da 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -33,7 +33,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 @@ -95,6 +95,8 @@ 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 wallet creation launcher run: dart scripts/tests/wallet_creation_runner_test.dart - name: Test diff --git a/Dockerfile b/Dockerfile index b90b15498b..3a04c6710b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,6 @@ ENV RUSTUP_HOME=/usr/local/rustup \ 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" @@ -202,7 +201,7 @@ 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. +# The dnix packages use prebuilts. Xelis still needs Rust for its build hook. ENV RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo \ PATH=/usr/local/cargo/bin:$PATH diff --git a/docs/building.md b/docs/building.md index ab99a2e8e5..c0a1d1c72d 100644 --- a/docs/building.md +++ b/docs/building.md @@ -1,193 +1,252 @@ # 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 an entry for each enabled package: + +```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`. +Remove any package-local `.dnix-build` development override when checking a +release: dnix gives that file precedence over the app's settings, and `dev` +mode skips hash verification. See [dnix build modes](https://github.com/cypherstack/dnix/blob/main/doc/configuration.md#build-modes). + +### 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 | +| Xelis (Stack Wallet) | Rustup on the Flutter host; 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 Xelis and for +an optional Isar source build (`-f`). 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. - -Make a Pixel 4 (API 30) x86_64 emulator with 2GB of storage space for emulation. - -The following *may* be needed for Android studio: -``` -sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386 +```sh +sudo apt-get install meson libgirepository1.0-dev valac xsltproc \ + docbook-xsl libgcrypt20-dev libglib2.0-dev ``` -### Build dependencies -Install basic dependencies -``` -sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm lld g++ gcc gperf python3-typogrify xsltproc valac gobject-introspection meson pipx libgcrypt20-dev libglib2.0-dev libsecret-1-dev -pipx install meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3.1.2 pygments==2.13.0 toml==0.10.2 typogrify==2.0.7 tomli==2.0.1 -``` +For Stack Wallet, install [Rustup](https://rustup.rs/) and +[Go](https://go.dev/doc/install). Xelis installs its pinned Rust toolchain on +first use; you can install it ahead of time with `rustup toolchain install +1.99.1`. -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 -``` +### Android -Android specific dependencies: -``` -sudo apt-get install libc6-dev-i386 -rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android -``` +Install [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. -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 -``` +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. -### Flutter -Install Flutter 3.47.2 by [following their guide](https://docs.flutter.dev/install/manual). +## Mac host -Run `flutter doctor` in a terminal to confirm its installation. +Install Flutter 3.47.2 on an Apple Silicon Mac. For source mode, install Nix +and **Xcode 27.0**, select that Xcode installation with `xcode-select`, and +complete its first-launch setup. The dnix hook checks the Xcode version when +building Apple targets. Prebuilt mode still needs the Xcode installation +required to build the Flutter app. -### 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 -``` +Install [Homebrew](https://brew.sh/) and the remaining native/plugin tools: -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 +brew install autoconf automake libtool cmake cocoapods pkg-config ``` -### 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` +For Stack Wallet, also install Rustup and Go. 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. -or by using `scripts/linux/build_secp256k1.sh` or `scripts/windows/build_secp256k1.bat`. +## Windows host -### Run prebuild script +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: -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. +- [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 Stack Wallet's Xelis hook. +- WSL2 and Nix inside its Linux distribution for dnix source mode. -### 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. +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: -Options: +```powershell +wsl.exe --list --verbose +wsl.exe -d Ubuntu-24.04 -- /nix/var/nix/profiles/default/bin/nix --version +``` - - `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. +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. -For example, -``` -./build_app.sh -a stack_wallet -p linux -v 2.1.0 -b 210 -``` +By default, dnix uses the default WSL distribution. You can choose it with +`wsl.exe --set-default Ubuntu-24.04`, or add +`wsl_distribution: Ubuntu-24.04` beside `build: source` in **each** enabled +package's hook settings. Keep native package 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. -#### Building plugins for Android -> Warning: This will take a long time, please be patient -``` -cd scripts -./build_app.sh -a stack_wallet -p android -``` +### MWEB and secp256k1 -#### Building plugins for Linux -``` -cd scripts -./build_app.sh -a stack_wallet -p linux -``` +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`. -#### Building plugins and configure for Windows -*This step is only necessary inside WSL2 for building on a Windows host.* +In source mode, `build_app.sh` builds secp256k1 natively when invoked from Git +Bash. With `-d` or `-i`, build it separately after configuring the app: -Install dependencies like MXE: -``` -cd scripts/windows -./deps.sh +```sh +flutter pub get +dart run coinlib:build_windows ``` -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 -``` +The legacy Linux/WSL configuration path still uses +`scripts/windows/build_secp256k1_wsl.sh` and needs CMake plus MinGW in WSL. +The old MXE setup and Epic-specific CMake upgrade are not needed for dnix. -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. +## Configure and build -go version should be at least 1.24 +Clone the repository, then run the setup from its root: -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 +```sh +git clone https://github.com/cypherstack/stack_wallet.git +cd stack_wallet +bash scripts/prebuild.sh ``` -### Running -#### Android -Plug in your android device or use the emulator available via Android Studio and then run the following commands: -``` -flutter pub get -flutter run android -``` +`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`. -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. +All four configuration arguments are required: -#### Linux -Run the following commands or launch via Android Studio: -``` +```sh +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s flutter pub get -flutter run linux +flutter build linux --release ``` -#### Linux release bundle preparation +Use the appropriate version/build number for your checkout and read the +script's confirmation before proceeding. To use verified dnix prebuilts, +add `-d` to the configuration command. For example: + +```sh +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -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 | +| `-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` | + +The configuration script also runs asset generators on non-Linux targets. +Those `dart run` commands can execute native-assets hooks, so install the +required source-build tools **before** running the configuration script. +The selected mode persists for subsequent `flutter run`, `flutter test`, and +`flutter build` commands 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, run `dart run coinlib:build_macos` after `flutter pub get` to prepare +the separate desktop secp256k1 library. + +### 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 +259,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 +268,24 @@ Standalone regression tests (also require a C compiler): python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v ``` -## Mac host - -### Dependencies -XCode, Homebrew and several homebrew packages, Rust, and Flutter are required for Mac development with the Flutter SDK. Multiple IDEs may work, but Android Studio is recommended. - -Download and install Xcode at https://developer.apple.com/xcode/, register your device (Mac or iPhone), and enable developer mode for your device as applicable. After installing XCode, make sure commandline tools are installed with `xcode-select --install`. - -Download and install [Homebrew](https://brew.sh/). The following command can install it via script: -``` -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` - -After installing Homebrew, install the following packages: -``` -brew install autoconf automake boost berkeley-db ca-certificates cbindgen cmake cocoapods curl git libssh2 libsodium make openssl@1.1 openssl@3 perl pkg-config rustup-init unbound unzip xz zmq -``` - -The following brew formula *may* be needed: -``` -brew install brotli cairo coreutils gdbm gettext glib gmp libevent libidn2 libnghttp2 libtool libunistring libx11 libxau libxcb libxdmcp libxext libxrender lzo m4 openldap pcre2 pixman procs rtmpdump tcl-tk xorgproto zstd -``` - - -Download and install [Rust](https://www.rust-lang.org/tools/install). [Rustup](https://rustup.rs/) is recommended for Rust setup. Use `rustc` to confirm successful installation. Install toolchain 1.99.1 as well as `cbindgen` and `cargo-lipo` too. You will also have to add the platform target(s) `aarch64-apple-ios` and/or `aarch64-apple-darwin`. You can use the command(s): -``` -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -source ~/.bashrc -rustup install 1.99.1 -rustup default 1.99.1 -cargo install cargo-ndk -cargo install cbindgen cargo-lipo -rustup target add aarch64-apple-ios aarch64-apple-darwin -``` - -Optionally download [Android Studio](https://developer.android.com/studio) as an IDE and activate its Dart and Flutter plugins. VS Code may work as an alternative, but this is not recommended. +## CI and configuration checks -### 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. +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. +Xelis, Spark, and other non-dnix components keep their existing build paths. -### 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. +After configuring an app and running `flutter pub get`, check template/mode +selection without compiling native libraries: -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 -``` +The configuration tests exercise all three apps, switching modes, and the +Windows/WSL secp256k1 dispatch using stubbed native tools. 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/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..a9e8ca3b70 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,27 @@ 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: +# 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. +# The app's existing dependency markers also enable its hook configuration. +hooks: + user_defines: +# %%ENABLE_FROST%% +# frostdart: +# build: PLACEHOLDER_DNIX_BUILD_MODE +# %%END_ENABLE_FROST%% +# %%ENABLE_TOR%% # 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" +# build: PLACEHOLDER_DNIX_BUILD_MODE +# %%END_ENABLE_TOR%% +# %%ENABLE_EPIC%% # 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" +# build: PLACEHOLDER_DNIX_BUILD_MODE +# %%END_ENABLE_EPIC%% +# %%ENABLE_MWC%% # 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%% +# build: PLACEHOLDER_DNIX_BUILD_MODE +# %%END_ENABLE_MWC%% # 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..75546f54e8 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -7,7 +7,11 @@ 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 " 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." + exit "${1:-1}" } confirmDisclaimer() { @@ -31,24 +35,23 @@ 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 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: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 ;; + h) usage 0 ;; *) usage ;; esac done @@ -112,11 +115,9 @@ 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 +# Render the mode before any Flutter/Dart command that can run build hooks. +sed -i.bak "s/PLACEHOLDER_DNIX_BUILD_MODE/${DNIX_BUILD_MODE}/g" "${ACTUAL_PUBSPEC}" +rm -f "${ACTUAL_PUBSPEC}.bak" "${APP_PROJECT_ROOT_DIR}/scripts/app_config/platforms/${APP_BUILD_PLATFORM}/platform_config.sh" if [[ "$APP_BUILD_PLATFORM" != "linux" ]]; then @@ -125,7 +126,7 @@ if [[ "$APP_BUILD_PLATFORM" != "linux" ]]; then fi # 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,11 +137,21 @@ 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 + # Prebuilt jobs build secp256k1 separately after pub get, as before. + if [ "$DNIX_BUILD_MODE" = source ]; then + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + pushd "${APP_PROJECT_ROOT_DIR}" + cmd.exe /c dart run coinlib:build_windows + popd + ;; + Linux*) + pushd "${APP_PROJECT_ROOT_DIR}/scripts/windows" + ./build_secp256k1_wsl.sh + popd + ;; + *) echo "Building Windows secp256k1 needs Windows or Linux/WSL." >&2; exit 1 ;; + esac fi ;; esac diff --git a/scripts/tests/native_build_config_test.dart b/scripts/tests/native_build_config_test.dart new file mode 100644 index 0000000000..8a02645be0 --- /dev/null +++ b/scripts/tests/native_build_config_test.dart @@ -0,0 +1,241 @@ +// 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'; + +void main() { + final repository = File.fromUri(Platform.script).parent.parent.parent; + const packages = { + 'stack_wallet': { + 'frostdart', + 'tor_ffi_plugin', + 'flutter_libepiccash', + 'flutter_libmwc', + }, + 'stack_duo': {'frostdart', 'tor_ffi_plugin'}, + 'campfire': {'tor_ffi_plugin'}, + }; + final allPackages = packages['stack_wallet']!; + 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/gen_interfaces.dart', + for (final app in packages.keys) 'scripts/app_config/configure_$app.sh', + ]) { + copyFile(file); + } + copyDirectory('scripts/app_config/templates'); + copyDirectory('tool/wl_templates'); + for (final app in packages.keys) { + 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-hooks.yaml" +'''); + for (final platform in ['macos', 'windows']) { + script('scripts/app_config/platforms/$platform/platform_config.sh', ':'); + } + script('scripts/windows/build_secp256k1_wsl.sh', r''' +printf 'wsl\n' > "$APP_PROJECT_ROOT_DIR/secp-host" +'''); + script('bin/cmd.exe', r''' +printf '%s\n' "$@" > "$APP_PROJECT_ROOT_DIR/windows-command" +'''); + script('bin/git', r''' +test "$1" = log +printf '0000000000000000000000000000000000000000\n' +'''); + // Invoke this SDK directly: Flutter's dart launcher also calls git/uname, + // which are simulated below to exercise the Windows dispatch on POSIX. + final dart = Platform.resolvedExecutable.replaceAll("'", "'\\''"); + script('bin/dart', "exec '$dart' \"\$@\""); + script('bin/uname', r''' +if [ -n "${DNIX_TEST_HOST:-}" ]; then + printf '%s\n' "$DNIX_TEST_HOST" +else + exec /usr/bin/uname "$@" +fi +'''); + script('bin/sha256sum', "printf 'fixture-hash mwebd.exe\\n'"); + 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? host, + }) async { + final process = await Process.start( + 'bash', + [ + 'scripts/build_app.sh', + '-a', + app, + '-p', + platform, + '-v', + '1.2.3', + '-b', + '123', + if (prebuilt) '-d', + if (skipNative) '-i', + ], + workingDirectory: root.path, + environment: { + 'PATH': '${bin.path}:${Platform.environment['PATH']}', + if (host != null) 'DNIX_TEST_HOST': host, + }, + ); + 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, 0, reason: '${await output}\n${await errors}'); + } + + void expectMode(String app, String mode) { + final text = File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(); + final pubspec = loadYaml(text) as YamlMap; + final dependencies = pubspec['dependencies'] as YamlMap; + final defines = (pubspec['hooks'] as YamlMap)['user_defines'] as YamlMap; + expect( + dependencies.keys.where(allPackages.contains).toSet(), + packages[app], + ); + expect(defines.keys.toSet(), packages[app]); + for (final package in packages[app]!) { + expect(defines[package], {'build': mode}); + } + expect(text, isNot(contains('PLACEHOLDER_DNIX_BUILD_MODE'))); + expect(text, isNot(contains('native_build:'))); + expect(text, isNot(contains('prebuilt_manifest_'))); + // Asset generators use dart run, which can execute native build hooks. + expect( + File(p.join(root.path, 'before-hooks.yaml')).readAsStringSync(), + text, + ); + } + + group('dnix configuration', () { + for (final app in packages.keys) { + test( + '$app switches source -> prebuilt -> source without stale settings', + () async { + for (final prebuilt in [false, true, false]) { + await configure(app, prebuilt: prebuilt); + expectMode(app, prebuilt ? 'prebuilt' : 'source'); + } + }, + ); + } + + test( + 'native Windows uses coinlib instead of the WSL cross compiler', + () async { + await configure( + 'stack_wallet', + platform: 'windows', + host: 'MINGW64_NT', + skipNative: false, + ); + expectMode('stack_wallet', 'source'); + expect(File(p.join(root.path, 'windows-command')).readAsLinesSync(), [ + '/c', + 'dart', + 'run', + 'coinlib:build_windows', + ]); + expect(File(p.join(root.path, 'secp-host')).existsSync(), isFalse); + }, + ); + + test('Linux/WSL keeps the secp256k1 cross-build path', () async { + await configure( + 'stack_wallet', + platform: 'windows', + host: 'Linux', + skipNative: false, + ); + expect(File(p.join(root.path, 'secp-host')).readAsStringSync(), 'wsl\n'); + expect(File(p.join(root.path, 'windows-command')).existsSync(), isFalse); + }); + + test( + '-d leaves the separate Windows secp256k1 build to the caller', + () async { + await configure( + 'stack_wallet', + platform: 'windows', + host: 'MINGW64_NT', + skipNative: false, + prebuilt: true, + ); + expectMode('stack_wallet', 'prebuilt'); + expect(File(p.join(root.path, 'secp-host')).existsSync(), isFalse); + expect( + File(p.join(root.path, 'windows-command')).existsSync(), + isFalse, + ); + }, + ); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); +} From f6939e49d5781ebd3ba5bf7b70c184dfcbb93f46 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Mon, 9 Nov 2026 09:55:57 -0600 Subject: [PATCH 02/13] Add isolated Xcode 27 setup helper and installation docs Document xcodes and Apple download installation alongside existing Xcode versions, using DEVELOPER_DIR for builds without changing the system default. Launch installation in a separate Terminal session with a clean environment. Download using xcodes --no-superuser, then invoke only the selected xcodebuild via sudo -k so sudo reads the administrator password and does not cache authorization. Never run build commands from the installer. Validation: 10 fixture tests passed, including environment isolation, shell/AppleScript quoting, reuse and failure paths. Dart analysis, formatting, shell syntax and workflow lint pass. No real Xcode installation or privileged setup was run. --- .github/workflows/test.yaml | 2 + docs/building.md | 70 +++++++- scripts/tests/xcode_setup_test.dart | 239 ++++++++++++++++++++++++++++ tool/setup_xcode.dart | 171 ++++++++++++++++++++ 4 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 scripts/tests/xcode_setup_test.dart create mode 100644 tool/setup_xcode.dart diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d11ac824da..7298ca201f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -97,6 +97,8 @@ jobs: run: dart scripts/tests/build_input_test.dart - name: Test native build configuration run: dart scripts/tests/native_build_config_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/docs/building.md b/docs/building.md index c0a1d1c72d..295a9283f3 100644 --- a/docs/building.md +++ b/docs/building.md @@ -109,11 +109,71 @@ not supported. Enable KVM for hardware-accelerated emulation on Linux. ## Mac host -Install Flutter 3.47.2 on an Apple Silicon Mac. For source mode, install Nix -and **Xcode 27.0**, select that Xcode installation with `xcode-select`, and -complete its first-launch setup. The dnix hook checks the Xcode version when -building Apple targets. Prebuilt mode still needs the Xcode installation -required to build the Flutter app. +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**; 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 finds the pinned version there automatically; +you can keep your existing system-wide `xcode-select` setting. + +### Install with xcodes + +Install the [xcodes CLI](https://github.com/XcodesOrg/xcodes), then run our +standalone helper from the repository root: + +```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 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. `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`, 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 that shell only. Substitute the +path printed by the helper (`xcodes` may name the bundle `Xcode-27.0.0.app`): + +```sh +export DEVELOPER_DIR=/Applications/Xcode-27.0.app/Contents/Developer +xcodebuild -version +flutter doctor +``` + +Run the configuration and Flutter build commands in this shell. `unset +DEVELOPER_DIR` returns it to the system default. There is no need for +`sudo xcode-select --switch`. For iOS simulation, install the required simulator +runtime from this Xcode's Settings > Components. + +### Other Mac tools Install [Homebrew](https://brew.sh/) and the remaining native/plugin tools: diff --git a/scripts/tests/xcode_setup_test.dart b/scripts/tests/xcode_setup_test.dart new file mode 100644 index 0000000000..bb6c2017fc --- /dev/null +++ b/scripts/tests/xcode_setup_test.dart @@ -0,0 +1,239 @@ +// 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() { + late Directory root; + late Directory apps; + late Directory bin; + late File calls; + + 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) { + final contents = '${apps.path}/$name/Contents'; + Directory(contents).createSync(recursive: true); + File('$contents/Info.plist').writeAsStringSync(version); + script('$contents/Developer/usr/bin/xcodebuild', r''' +case "$1" in + -version) printf 'Xcode %s\nBuild version fixture\n' "${REPORTED_VERSION:-27.0}" ;; + -runFirstLaunch) echo setup >> "$CALLS"; exit "${SETUP_EXIT:-0}" ;; + -checkFirstLaunchStatus) echo checked >> "$CALLS" ;; + *) exit 99 ;; +esac +'''); + } + + setUp(() { + // Quoting must survive both the shell and AppleScript layers. + root = Directory.systemTemp.createTempSync("xcode setup ' quotes "); + apps = Directory('${root.path}/Applications')..createSync(); + bin = Directory('${root.path}/bin')..createSync(); + calls = File('${root.path}/calls'); + script('${bin.path}/id', 'echo "\${FIXTURE_UID:-1000}"'); + script('${bin.path}/uname', 'echo arm64'); + script('${bin.path}/PlistBuddy', r'cat "$3"'); + script('${bin.path}/sudo', r''' +# Exercise the real command sequence without invoking sudo or a real Xcode. +test "$#" = 3 +test "$1" = -k +test "$3" = -runFirstLaunch +echo sudo-without-cache >> "$CALLS" +shift +"$@" +'''); + script('${bin.path}/xcodes', r''' +if [ "$*" = 'install --help' ]; then + echo "${HELP_FLAG:---no-superuser}" + exit 0 +fi +test "$1" = install +test "$2" = 27.0 +test "$3" = --directory +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', '27.0'); + Directory('${apps.path}/Spare.app').renameSync('${root.path}/Spare.app'); + }); + tearDown(() => root.delete(recursive: true)); + + Future runInstaller([Map env = const {}]) { + // Redirect ONLY native/system operations into the fixture. No real sudo, + // xcodes, Xcode, downloads, or Terminal windows are used by these tests. + var installer = setup.installerScript + .replaceAll( + '/Applications/Xcode*.app', + '${setup.shellQuote(apps.path)}/Xcode*.app', + ) + .replaceAll( + '--directory /Applications', + '--directory ${setup.shellQuote(apps.path)}', + ) + .replaceAll('[ ! -t 0 ]', '[ "\${FIXTURE_TTY:-1}" != 1 ]'); + for (final entry in { + '/usr/bin/id': 'id', + '/usr/bin/uname': 'uname', + '/usr/bin/sudo': 'sudo', + '/usr/libexec/PlistBuddy': 'PlistBuddy', + }.entries) { + installer = installer.replaceAll( + entry.key, + setup.shellQuote('${bin.path}/${entry.value}'), + ); + } + return Process.run( + '/bin/bash', + ['--noprofile', '--norc', '-c', installer], + includeParentEnvironment: false, + environment: { + 'HOME': root.path, + 'PATH': '${bin.path}:/usr/bin:/bin', + 'CALLS': calls.path, + 'SPARE': '${root.path}/Spare.app', + ...env, + }, + ); + } + + group('Xcode setup', () { + test('downloads with no sudo, then initializes only Xcode 27.0', () async { + xcode('Xcode.app', '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( + File('${apps.path}/Xcode.app/Contents/Info.plist').readAsStringSync(), + '26.0', + ); + }); + + test('reuses a manual installation and retries unfinished setup', () async { + xcode("Xcode-27.0 user's.app", '27.0'); + File('${bin.path}/xcodes').deleteSync(); + final result = await runInstaller(); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(calls.readAsLinesSync(), [ + 'sudo-without-cache', + 'setup', + 'checked', + ]); + }); + + test('download failure never reaches sudo', () async { + final result = await runInstaller({'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({'HELP_FLAG': 'old version'}); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }, + ); + + test('rejects a mismatched executable before sudo', () async { + xcode('Xcode-27.0.app', '27.0'); + final result = await runInstaller({'REPORTED_VERSION': '26.0'}); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }); + + test('setup failure does not claim success', () async { + xcode('Xcode-27.0.app', '27.0'); + final result = await runInstaller({'SETUP_EXIT': '43'}); + expect(result.exitCode, 43); + expect(calls.readAsLinesSync(), ['sudo-without-cache', 'setup']); + expect(result.stdout, isNot(contains('Setup complete'))); + }); + + for (final env in [ + {'FIXTURE_UID': '0'}, + {'FIXTURE_TTY': '0'}, + ]) { + test('rejects an unsafe session: ${env.keys.single}', () async { + final result = await runInstaller(env); + expect(result.exitCode, isNot(0)); + expect(calls.existsSync(), isFalse); + }); + } + + test( + 'launcher drops build environment and safely quotes account data', + () async { + final home = "${root.path}/\$(touch injected)"; + final command = setup + .terminalCommand(home: home, user: "user's name") + .replaceFirst( + setup.shellQuote(setup.installerScript), + setup.shellQuote('/usr/bin/env'), + ); + final result = await Process.run( + '/bin/sh', + ['-c', command], + workingDirectory: root.path, + includeParentEnvironment: false, + environment: { + 'BUILD_ONLY': 'fixture', + 'XCODES_PASSWORD': 'fixture', + 'DEVELOPER_DIR': '/wrong/Xcode.app', + 'SUDO_ASKPASS': '/wrong/helper', + 'BASH_ENV': '/wrong/startup', + }, + ); + expect(result.exitCode, 0, reason: '${result.stderr}'); + final output = result.stdout as String; + expect(output, contains('HOME=$home\n')); + expect(output, contains("USER=user's name\n")); + for (final name in [ + 'BUILD_ONLY', + 'XCODES_PASSWORD', + 'DEVELOPER_DIR', + 'SUDO_ASKPASS', + 'BASH_ENV', + ]) { + expect(output, isNot(contains('$name='))); + } + expect(File('${root.path}/injected').existsSync(), isFalse); + }, + ); + + test('AppleScript round-trips the command without executing it', () async { + final command = setup.terminalCommand( + home: '/Users/a "quoted" \\ name\nnext line', + user: "user's name", + ); + final appleScript = setup.terminalAppleScript(command); + final literal = appleScript + .split('\n')[2] + .substring(' do script '.length); + final result = await Process.run('/usr/bin/osascript', [ + '-e', + 'return $literal', + ]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(result.stdout, '$command\n'); + }, skip: !Platform.isMacOS); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); +} diff --git a/tool/setup_xcode.dart b/tool/setup_xcode.dart new file mode 100644 index 0000000000..4296a0247c --- /dev/null +++ b/tool/setup_xcode.dart @@ -0,0 +1,171 @@ +// Run directly with `dart tool/setup_xcode.dart`; no pub get/build hooks needed. +import 'dart:io'; + +const xcodeVersion = '27.0'; + +// This runs only in the new Terminal session, never in the build process. +// Keep privileged operations explicit and use sudo -k WITH a command: sudo +// prompts itself and neither reads nor updates its cached credentials. +const installerScript = r''' +set -euo pipefail +umask 077 +cd "$HOME" + +if [ "$(/usr/bin/id -u)" = 0 ]; then + echo 'Run this helper as your normal macOS user, without sudo.' >&2 + exit 1 +fi +if [ "$(/usr/bin/uname -m)" != arm64 ]; then + echo 'This setup helper requires an Apple Silicon Mac.' >&2 + exit 1 +fi +if [ ! -t 0 ]; then + echo 'Run this setup in its own interactive Terminal window.' >&2 + exit 1 +fi + +find_xcode() { + local candidate version + for candidate in /Applications/Xcode*.app; do + [ -f "$candidate/Contents/Info.plist" ] || continue + version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + "$candidate/Contents/Info.plist" 2>/dev/null) || continue + case "$version" in + 27.0|27.0.0) XCODE_APP="$candidate"; return 0 ;; + esac + done + return 1 +} + +if ! find_xcode; then + if ! command -v xcodes >/dev/null 2>&1; then + echo 'Install xcodes first: brew install xcodesorg/made/xcodes' >&2 + exit 1 + fi + help=$(xcodes install --help) + if [[ "$help" != *--no-superuser* ]]; then + echo 'Update xcodes: brew upgrade xcodesorg/made/xcodes' >&2 + exit 1 + fi + # xcodes handles Apple account authentication. It never receives sudo's password. + xcodes install 27.0 --directory /Applications --no-superuser --update + if ! find_xcode; then + echo 'Installation did not produce Xcode 27.0 under /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%%$'\n'*}" != 'Xcode 27.0' ]; 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.' +# An absolute executable, no root shell, no -S, no sudo -v, no cached ticket. +/usr/bin/sudo -k "$xcodebuild" -runFirstLaunch + +"$xcodebuild" -checkFirstLaunchStatus +printf '\n%s\n' "$version" +echo 'Setup complete. The system-wide Xcode selection is unchanged.' +echo 'In your build terminal, use:' +printf 'export DEVELOPER_DIR=%q\n' "$DEVELOPER_DIR" +'''; + +String shellQuote(String value) => "'${value.replaceAll("'", "'\\''")}'"; + +String terminalCommand({required String home, required String user}) { + // Do not forward PATH, BASH_ENV, shell options, Apple account credentials, + // or any other environment from the caller/build. No shell startup files. + final command = [ + '/usr/bin/env', + '-i', + 'HOME=$home', + 'USER=$user', + 'LOGNAME=$user', + 'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', + 'TERM=xterm-256color', + '/bin/bash', + '--noprofile', + '--norc', + '-c', + installerScript, + ]; + // Replace the new terminal's shell so it cannot become a later build shell. + return 'exec ${command.map(shellQuote).join(' ')}'; +} + +String terminalAppleScript(String command) { + final quoted = command + .replaceAll(r'\', r'\\') + .replaceAll('"', r'\"') + .replaceAll('\r', r'\r') + .replaceAll('\n', r'\n'); + // No "in window/tab": Terminal creates a new session with its own TTY. + return 'tell application "Terminal"\n' + ' activate\n' + ' do script "$quoted"\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 Xcode $xcodeVersion in a separate Terminal window using xcodes. +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 Terminal command 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 command = terminalCommand(home: home, user: user); + if (args.isNotEmpty) { + stdout.writeln(command); + return; + } + if (!Platform.isMacOS) { + stderr.writeln('Xcode setup requires macOS.'); + exitCode = 1; + return; + } + 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 launch = await Process.run('/usr/bin/osascript', [ + '-e', + terminalAppleScript(command), + ], includeParentEnvironment: false); + if (launch.exitCode != 0) { + stderr.writeln('Could not open Terminal: ${launch.stderr}'); + exitCode = launch.exitCode; + return; + } + stdout.writeln( + 'Setup launched in a separate Terminal window. Finish the prompts there ' + 'and wait for "Setup complete" before building. This launcher does not ' + 'receive passwords or report installation success.', + ); +} From e9224f90b0eb41855e784877fc1e8871403cb57e Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Mon, 9 Nov 2026 14:51:24 -0600 Subject: [PATCH 03/13] Validate native build prerequisites and fix Windows and Xcode setup Keep Rustup for DNSSEC in every app and CI job. Check prerequisites before replacing configuration, persist WSL selection through an environment default or -w, and correct the docs about executable hook closures and DEVELOPER_DIR. Build Windows secp256k1 independently of dnix mode using one pinned v0.5.0 source for MSVC and MinGW. Discover VS CMake and SDK tools, stop on failed commands, and reuse only matching local build receipts. Fix PowerShell prebuild paths, malformed stubs and write failures. Select the Xcode 27.0 release build, support xcodes outside Homebrew, launch a shell-portable installer in one Terminal session, and keep results visible. Detect dnix's separate beta-selection limitation without changing global Xcode settings. Inject native tools into tests instead of rewriting privileged command strings. Validation: 92 targeted Dart tests and 4 PowerShell regression cases passed; Dart analysis/formatting, Bash syntax, workflow lint and diff checks passed. No full native Windows app build or privileged Xcode installation ran. Package-level xcodeBuild pins remain a documented upstream follow-up. --- .github/workflows/build.yaml | 56 ++- .github/workflows/test.yaml | 14 + Dockerfile | 9 +- docs/building.md | 125 ++++-- .../templates/pubspec.template.yaml | 4 + scripts/build_app.sh | 46 ++- scripts/prebuild.ps1 | 20 +- scripts/tests/build_environment_test.dart | 297 ++++++++++++++ scripts/tests/native_build_config_test.dart | 253 +++++++++--- scripts/tests/prebuild_windows_test.ps1 | 64 +++ scripts/tests/windows_secp_build_test.dart | 295 ++++++++++++++ scripts/tests/xcode_setup_test.dart | 361 +++++++++++------ scripts/windows/build_secp256k1.bat | 17 +- scripts/windows/build_secp256k1.dart | 371 ++++++++++++++++++ scripts/windows/build_secp256k1_wsl.sh | 19 +- tool/check_build_environment.dart | 298 ++++++++++++++ tool/configure_native_assets.dart | 55 +++ tool/setup_xcode.dart | 255 +++++++++--- 18 files changed, 2205 insertions(+), 354 deletions(-) create mode 100644 scripts/tests/build_environment_test.dart create mode 100644 scripts/tests/prebuild_windows_test.ps1 create mode 100644 scripts/tests/windows_secp_build_test.dart create mode 100644 scripts/windows/build_secp256k1.dart mode change 100644 => 100755 scripts/windows/build_secp256k1_wsl.sh create mode 100644 tool/check_build_environment.dart create mode 100644 tool/configure_native_assets.dart diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f34bf63539..6ab59ced21 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,7 +18,8 @@ on: default: '1' # Configure app uses -d for verified dnix prebuilts (Frost, Tor, Epic and MWC). -# Xelis and Spark still use their own source-build hooks. +# 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: @@ -231,6 +232,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' @@ -280,9 +286,6 @@ jobs: 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 +325,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' @@ -386,10 +394,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: @@ -811,6 +819,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' @@ -852,9 +865,6 @@ jobs: 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 @@ -894,6 +904,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' @@ -958,6 +973,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' @@ -1244,6 +1264,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' @@ -1285,9 +1310,6 @@ jobs: 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 @@ -1327,6 +1349,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' @@ -1391,6 +1418,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' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7298ca201f..87b2486915 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: @@ -97,6 +107,10 @@ jobs: run: dart scripts/tests/build_input_test.dart - name: Test native build configuration run: dart scripts/tests/native_build_config_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 diff --git a/Dockerfile b/Dockerfile index 3a04c6710b..a38b6a7638 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ ENV RUSTUP_HOME=/usr/local/rustup \ 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 toolchain install 1.90.0 --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" @@ -95,7 +96,8 @@ RUN git config --system --add safe.directory '*' RUN flutter --version && rustc --version && cargo --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; +# pinned Rust toolchains for DNSSEC and Xelis, with Android targets. FROM ubuntu:24.04 AS android ENV DEBIAN_FRONTEND=noninteractive \ @@ -118,6 +120,7 @@ ENV RUSTUP_HOME=/usr/local/rustup \ 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 toolchain install 1.90.0 --profile minimal \ && rustup target add \ aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \ --toolchain 1.99.1 \ @@ -201,13 +204,15 @@ 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/* -# The dnix packages use prebuilts. Xelis still needs Rust for its build hook. +# dnix uses prebuilts; all apps need Rust 1.90.0 for DNSSEC, plus +# Rust 1.99.1 for Stack Wallet's Xelis hook. ENV RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo \ 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 toolchain install 1.90.0 --profile minimal \ && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" ENV PATH=/usr/local/go/bin:$PATH diff --git a/docs/building.md b/docs/building.md index 295a9283f3..feefb5842b 100644 --- a/docs/building.md +++ b/docs/building.md @@ -51,7 +51,8 @@ mode skips hash verification. See [dnix build modes](https://github.com/cypherst | 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 | -| Xelis (Stack Wallet) | Rustup on the Flutter host; its hook installs Rust **1.99.1** and the required target; Android needs NDK r28 or newer | +| 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 | @@ -59,8 +60,11 @@ mode skips hash verification. See [dnix build modes](https://github.com/cypherst `-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 Xelis and for -an optional Isar source build (`-f`). Nix supplies the four dnix packages' own +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 @@ -90,10 +94,10 @@ sudo apt-get install meson libgirepository1.0-dev valac xsltproc \ docbook-xsl libgcrypt20-dev libglib2.0-dev ``` -For Stack Wallet, install [Rustup](https://rustup.rs/) and -[Go](https://go.dev/doc/install). Xelis installs its pinned Rust toolchain on -first use; you can install it ahead of time with `rustup toolchain install -1.99.1`. +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. ### Android @@ -110,11 +114,12 @@ not supported. Enable KVM for hardware-accelerated emulation on Linux. ## Mac host 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**; a different version can -produce different hashes. Prebuilt mode still needs Xcode to build the Flutter +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 finds the pinned version there automatically; -you can keep your existing system-wide `xcode-select` setting. +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. ### Install with xcodes @@ -133,6 +138,11 @@ 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, or Nix installations. It selects the **27A266a** release +build and ignores 27.0 betas. The setup window waits for Return after displaying +its result, so you can copy the instructions even with a close-on-exit profile. + 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 @@ -154,13 +164,13 @@ Keychain according to its own settings. ``` The first command installs Apple's components and accepts the Xcode license. - Confirm the second prints `Xcode 27.0`, then close that setup window. You can - also run our Dart helper after moving the app; it reuses that installation. + 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 that shell only. Substitute the -path printed by the helper (`xcodes` may name the bundle `Xcode-27.0.0.app`): +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`): ```sh export DEVELOPER_DIR=/Applications/Xcode-27.0.app/Contents/Developer @@ -168,10 +178,20 @@ xcodebuild -version flutter doctor ``` -Run the configuration and Flutter build commands in this shell. `unset -DEVELOPER_DIR` returns it to the system default. There is no need for -`sudo xcode-select --switch`. For iOS simulation, install the required simulator -runtime from this Xcode's Settings > Components. +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. + +**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. ### Other Mac tools @@ -181,7 +201,7 @@ Install [Homebrew](https://brew.sh/) and the remaining native/plugin tools: brew install autoconf automake libtool cmake cocoapods pkg-config ``` -For Stack Wallet, also install Rustup and Go. You do not need to install +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. @@ -196,7 +216,7 @@ Install: - [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 Stack Wallet's Xelis hook. +- 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) @@ -214,10 +234,12 @@ 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. You can choose it with -`wsl.exe --set-default Ubuntu-24.04`, or add -`wsl_distribution: Ubuntu-24.04` beside `build: source` in **each** enabled -package's hook settings. Keep native package checkouts in LF form and preserve +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. @@ -231,17 +253,26 @@ 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 source mode, `build_app.sh` builds secp256k1 natively when invoked from Git -Bash. With `-d` or `-i`, build it separately after configuring the app: +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: ```sh -flutter pub get -dart run coinlib:build_windows +dart scripts/windows/build_secp256k1.dart ``` -The legacy Linux/WSL configuration path still uses -`scripts/windows/build_secp256k1_wsl.sh` and needs CMake plus MinGW in WSL. -The old MXE setup and Epic-specific CMake upgrade are not needed for dnix. +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. + +If configuring from the legacy Linux/WSL shell, keep its Linux Dart/Rustup tools +as well as Rustup on the native Windows Flutter host. The prerequisite check +can verify Rustup only for the Dart host running configuration. Windows builds +from WSL still probe the WSL distribution chosen for the native Windows hooks. ## Configure and build @@ -282,6 +313,7 @@ bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s -d | `-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: @@ -294,17 +326,20 @@ Choose `-p` for your target and then run the corresponding Flutter build: | iOS | `flutter build ios --release` | | Windows | `flutter build windows --release` | -The configuration script also runs asset generators on non-Linux targets. -Those `dart run` commands can execute native-assets hooks, so install the -required source-build tools **before** running the configuration script. -The selected mode persists for subsequent `flutter run`, `flutter test`, and -`flutter build` commands until you regenerate the configuration. +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, run `dart run coinlib:build_macos` after `flutter pub get` to prepare -the separate desktop secp256k1 library. +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 @@ -333,7 +368,8 @@ python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v 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. -Xelis, Spark, and other non-dnix components keep their existing build paths. +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. After configuring an app and running `flutter pub get`, check template/mode selection without compiling native libraries: @@ -343,9 +379,12 @@ dart scripts/tests/native_build_config_test.dart dart scripts/tests/build_input_test.dart ``` -The configuration tests exercise all three apps, switching modes, and the -Windows/WSL secp256k1 dispatch using stubbed native tools. Actual native Windows -to WSL builds are tested in dnix's CI; these app tests do not run WSL. +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/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index a9e8ca3b70..65b9d189d6 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -374,18 +374,22 @@ hooks: # %%ENABLE_FROST%% # frostdart: # build: PLACEHOLDER_DNIX_BUILD_MODE +# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION # %%END_ENABLE_FROST%% # %%ENABLE_TOR%% # tor_ffi_plugin: # build: PLACEHOLDER_DNIX_BUILD_MODE +# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION # %%END_ENABLE_TOR%% # %%ENABLE_EPIC%% # flutter_libepiccash: # build: PLACEHOLDER_DNIX_BUILD_MODE +# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION # %%END_ENABLE_EPIC%% # %%ENABLE_MWC%% # flutter_libmwc: # build: PLACEHOLDER_DNIX_BUILD_MODE +# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION # %%END_ENABLE_MWC%% # For information on the generic Dart part of this file, see the diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 75546f54e8..ed8c7deafb 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -6,11 +6,12 @@ source "$(dirname -- "${BASH_SOURCE[0]}")/env.sh" # Function to display usage. usage() { - echo "Usage: $0 -v -b -p -a [-d] [-i] [-f] [-s]" + 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}" } @@ -37,11 +38,12 @@ unset -v APP_NAMED_ID SKIP_NATIVE_DEPS_BUILD=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:idfsh" 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" ;; @@ -51,6 +53,7 @@ while getopts "v:b:p:a:idfsh" opt; do 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 @@ -97,6 +100,17 @@ if [[ ! "$APP_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then fi confirmDisclaimer + +# Check requirements before replacing any generated app/platform files. +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" @@ -115,9 +129,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}" -# Render the mode before any Flutter/Dart command that can run build hooks. -sed -i.bak "s/PLACEHOLDER_DNIX_BUILD_MODE/${DNIX_BUILD_MODE}/g" "${ACTUAL_PUBSPEC}" -rm -f "${ACTUAL_PUBSPEC}.bak" +# 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 @@ -137,22 +152,11 @@ if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then popd ;; windows) - # Prebuilt jobs build secp256k1 separately after pub get, as before. - if [ "$DNIX_BUILD_MODE" = source ]; then - case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) - pushd "${APP_PROJECT_ROOT_DIR}" - cmd.exe /c dart run coinlib:build_windows - popd - ;; - Linux*) - pushd "${APP_PROJECT_ROOT_DIR}/scripts/windows" - ./build_secp256k1_wsl.sh - popd - ;; - *) echo "Building Windows secp256k1 needs Windows or Linux/WSL." >&2; exit 1 ;; - esac - 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..fa275d1f30 --- /dev/null +++ b/scripts/tests/build_environment_test.dart @@ -0,0 +1,297 @@ +// 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/setup_xcode.dart' show xcodeBuild; + +class HostFixture { + String os = 'macos'; + String architecture = 'arm64'; + bool rustupOnPath = true; + bool rustupInHome = false; + 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 27.0\nBuild version $xcodeBuild\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 == '/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, + }) => checkBuildEnvironment( + host: host, + platform: platform, + app: app, + mode: mode, + wslDistribution: distribution, + ); +} + +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. + 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); + }, + ); + + 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('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/native_build_config_test.dart b/scripts/tests/native_build_config_test.dart index 8a02645be0..e68cb99769 100644 --- a/scripts/tests/native_build_config_test.dart +++ b/scripts/tests/native_build_config_test.dart @@ -6,6 +6,8 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; +import '../../tool/configure_native_assets.dart'; + void main() { final repository = File.fromUri(Platform.script).parent.parent.parent; const packages = { @@ -55,6 +57,7 @@ void main() { 'scripts/env.sh', 'scripts/app_config/shared/update_version.sh', 'tool/process_pubspec_deps.dart', + 'tool/configure_native_assets.dart', 'tool/gen_interfaces.dart', for (final app in packages.keys) 'scripts/app_config/configure_$app.sh', ]) { @@ -78,17 +81,17 @@ void main() { // 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-hooks.yaml" +cp "$APP_PROJECT_ROOT_DIR/pubspec.yaml" "$APP_PROJECT_ROOT_DIR/before-assets.yaml" '''); - for (final platform in ['macos', 'windows']) { + for (final platform in ['macos', 'windows', 'linux']) { script('scripts/app_config/platforms/$platform/platform_config.sh', ':'); } - script('scripts/windows/build_secp256k1_wsl.sh', r''' -printf 'wsl\n' > "$APP_PROJECT_ROOT_DIR/secp-host" -'''); - script('bin/cmd.exe', r''' -printf '%s\n' "$@" > "$APP_PROJECT_ROOT_DIR/windows-command" + 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' @@ -105,6 +108,36 @@ else fi '''); 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_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'; @@ -122,11 +155,15 @@ void main(List args) { bool skipNative = true, String platform = 'macos', String? host, + String? distribution, + bool fromScripts = false, + Map environment = const {}, + int expectedExit = 0, }) async { final process = await Process.start( 'bash', [ - 'scripts/build_app.sh', + fromScripts ? 'build_app.sh' : 'scripts/build_app.sh', '-a', app, '-p', @@ -137,11 +174,14 @@ void main(List args) { '123', if (prebuilt) '-d', if (skipNative) '-i', + if (distribution != null) ...['-w', distribution], ], - workingDirectory: root.path, + workingDirectory: fromScripts ? p.join(root.path, 'scripts') : root.path, environment: { 'PATH': '${bin.path}:${Platform.environment['PATH']}', + 'DNIX_WSL_DISTRIBUTION': '', if (host != null) 'DNIX_TEST_HOST': host, + ...environment, }, ); final output = process.stdout.transform(utf8.decoder).join(); @@ -149,10 +189,15 @@ void main(List args) { process.stdin.writeln('yes'); await process.stdin.close(); final code = await process.exitCode; - expect(code, 0, reason: '${await output}\n${await errors}'); + expect(code, expectedExit, reason: '${await output}\n${await errors}'); } - void expectMode(String app, String mode) { + void expectMode( + String app, + String mode, { + String? distribution, + bool linux = false, + }) { final text = File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(); final pubspec = loadYaml(text) as YamlMap; final dependencies = pubspec['dependencies'] as YamlMap; @@ -163,16 +208,23 @@ void main(List args) { ); expect(defines.keys.toSet(), packages[app]); for (final package in packages[app]!) { - expect(defines[package], {'build': mode}); + 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_'))); - // Asset generators use dart run, which can execute native build hooks. - expect( - File(p.join(root.path, 'before-hooks.yaml')).readAsStringSync(), - text, - ); + 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', () { @@ -188,54 +240,151 @@ void main(List args) { ); } - test( - 'native Windows uses coinlib instead of the WSL cross compiler', - () async { + for (final host in ['MINGW64_NT', 'Linux']) { + for (final prebuilt in [false, true]) { + test( + 'Windows config builds secp in project root on $host, -d=$prebuilt', + () async { + await configure( + 'stack_wallet', + platform: 'windows', + host: host, + skipNative: false, + prebuilt: prebuilt, + fromScripts: true, + ); + expectMode('stack_wallet', 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: 'windows', - host: 'MINGW64_NT', + platform: 'linux', skipNative: false, + prebuilt: prebuilt, ); - expectMode('stack_wallet', 'source'); - expect(File(p.join(root.path, 'windows-command')).readAsLinesSync(), [ - '/c', - 'dart', - 'run', - 'coinlib:build_windows', + expectMode( + 'stack_wallet', + prebuilt ? 'prebuilt' : 'source', + linux: true, + ); + expect(File(p.join(root.path, 'linux-deps')).readAsLinesSync(), [ + 'secure_storage_deps', + 'secp256k1', ]); - expect(File(p.join(root.path, 'secp-host')).existsSync(), isFalse); - }, - ); + expect( + File(p.join(root.path, 'native-cwd')).readAsStringSync().trim(), + Directory(p.join(root.path, 'scripts/linux')) + .resolveSymbolicLinksSync(), + ); + }); + } - test('Linux/WSL keeps the secp256k1 cross-build path', () async { - await configure( - 'stack_wallet', - platform: 'windows', - host: 'Linux', - skipNative: false, - ); - expect(File(p.join(root.path, 'secp-host')).readAsStringSync(), 'wsl\n'); - expect(File(p.join(root.path, 'windows-command')).existsSync(), isFalse); - }); + 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( - '-d leaves the separate Windows secp256k1 build to the caller', + '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( + 'stack_wallet', + 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', - platform: 'windows', - host: 'MINGW64_NT', - skipNative: false, - prebuilt: true, - ); - expectMode('stack_wallet', 'prebuilt'); - expect(File(p.join(root.path, 'secp-host')).existsSync(), isFalse); - expect( - File(p.join(root.path, 'windows-command')).existsSync(), - isFalse, + distribution: 'Ubuntu-24.04', + environment: {'DNIX_WSL_DISTRIBUTION': distro}, ); + expectMode('stack_wallet', 'source', distribution: 'Ubuntu-24.04'); + await configure('stack_wallet'); + expectMode('stack_wallet', 'source'); }, ); + + for (final failure in [ + (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('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/prebuild_windows_test.ps1 b/scripts/tests/prebuild_windows_test.ps1 new file mode 100644 index 0000000000..cdfcf5bd57 --- /dev/null +++ b/scripts/tests/prebuild_windows_test.ps1 @@ -0,0 +1,64 @@ +# Run with: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/prebuild_windows_test.ps1 +# No Flutter, package dependencies, credentials, or native libraries required. +$ErrorActionPreference = 'Stop' +$sourceScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'prebuild.ps1' +$fixtureBase = Join-Path ([System.IO.Path]::GetTempPath()) ('stack prebuild [paths] ' + [Guid]::NewGuid()) +$coins = @('bitcoin', 'bitcoincash', 'dogecoin', 'namecoin', 'firo', 'particl') +$expectedParameters = @' +const TEST_MNEMONIC = ""; +const ROOT_WIF = ""; +const NODE_WIF_84 = ""; +'@ + +function Assert($condition, $message) { + if (-not $condition) { throw $message } +} + +function New-Fixture($name) { + $root = Join-Path $fixtureBase $name + foreach ($relative in @('scripts', 'lib', 'elsewhere')) { + [System.IO.Directory]::CreateDirectory((Join-Path $root $relative)) | Out-Null + } + foreach ($coin in $coins) { + [System.IO.Directory]::CreateDirectory((Join-Path $root "test/services/coins/$coin")) | Out-Null + } + Copy-Item -LiteralPath $sourceScript -Destination (Join-Path $root 'scripts/prebuild.ps1') + return $root +} + +function Parameters-Path($root, $coin) { + return Join-Path $root "test/services/coins/$coin/${coin}_wallet_test_parameters.dart" +} + +try { + foreach ($cwd in @('.', 'elsewhere')) { + $root = New-Fixture ('cwd-' + [Guid]::NewGuid()) + Push-Location -LiteralPath (Join-Path $root $cwd) + try { & (Join-Path $root 'scripts/prebuild.ps1') } finally { Pop-Location } + Assert (Test-Path -LiteralPath (Join-Path $root 'lib/external_api_keys.dart') -PathType Leaf) 'API key stub was not written in the checkout.' + foreach ($coin in $coins) { + $actual = Get-Content -LiteralPath (Parameters-Path $root $coin) -Raw + Assert ($actual.TrimEnd() -eq $expectedParameters.TrimEnd()) "Invalid Dart wallet stub for $coin." + } + Assert (-not (Test-Path -LiteralPath (Join-Path $fixtureBase 'lib/external_api_keys.dart'))) 'prebuild wrote outside its checkout.' + } + + $root = New-Fixture 'preserve' + $keys = Join-Path $root 'lib/external_api_keys.dart' + $parameters = Parameters-Path $root 'bitcoin' + Set-Content -LiteralPath $keys -Value 'existing keys' + Set-Content -LiteralPath $parameters -Value 'existing wallet parameters' + & (Join-Path $root 'scripts/prebuild.ps1') + Assert ((Get-Content -LiteralPath $keys -Raw).TrimEnd() -eq 'existing keys') 'Existing keys were overwritten.' + Assert ((Get-Content -LiteralPath $parameters -Raw).TrimEnd() -eq 'existing wallet parameters') 'Existing parameters were overwritten.' + + $root = New-Fixture 'write-failure' + [System.IO.Directory]::CreateDirectory((Parameters-Path $root 'bitcoin')) | Out-Null + $failed = $false + try { & (Join-Path $root 'scripts/prebuild.ps1') } catch { $failed = $true } + Assert $failed 'A directory at a wallet stub path must fail the script.' + Assert (-not (Test-Path -LiteralPath (Parameters-Path $root 'bitcoincash'))) 'prebuild continued writing after a failure.' + Write-Host 'All 4 PowerShell prebuild tests passed.' +} finally { + if (Test-Path -LiteralPath $fixtureBase) { Remove-Item -LiteralPath $fixtureBase -Recurse -Force } +} diff --git a/scripts/tests/windows_secp_build_test.dart b/scripts/tests/windows_secp_build_test.dart new file mode 100644 index 0000000000..d16425b122 --- /dev/null +++ b/scripts/tests/windows_secp_build_test.dart @@ -0,0 +1,295 @@ +// Run with: dart scripts/tests/windows_secp_build_test.dart +import 'dart:io'; + +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; + + 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; + 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}, + 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 == 'clone') { + Directory(args.last).createSync(recursive: true); + } else if (operation != 'checkout') { + 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('clone'), 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('clone'), 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('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); + }); + + test('matching receipt avoids cloning and recompiling', () async { + await builder().build(); + calls.clear(); + await builder().build(); + expect(operation('clone'), 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('clone'), 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('clone'), hasLength(1)); + File('${root.path}/scripts/windows/build_secp256k1.dart') + .writeAsStringSync('changed recipe'); + calls.clear(); + await builder(nativeWindows: false).build(); + expect(operation('clone'), 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('clone'), 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('clone'), 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('clone'), hasLength(1)); + }); + + for (final operation in ['clone', '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); + }); +} diff --git a/scripts/tests/xcode_setup_test.dart b/scripts/tests/xcode_setup_test.dart index bb6c2017fc..98fd7ec26d 100644 --- a/scripts/tests/xcode_setup_test.dart +++ b/scripts/tests/xcode_setup_test.dart @@ -10,6 +10,7 @@ void main() { late Directory apps; late Directory bin; late File calls; + late setup.SetupTools tools; void script(String path, String body) { final file = File(path); @@ -18,13 +19,18 @@ void main() { expect(Process.runSync('chmod', ['+x', path]).exitCode, 0); } - void xcode(String name, String version) { + void xcode( + String name, { + String version = setup.xcodeVersion, + String build = setup.xcodeBuild, + }) { 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 fixture\n' "${REPORTED_VERSION:-27.0}" ;; + -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 ;; @@ -33,19 +39,26 @@ esac } setUp(() { - // Quoting must survive both the shell and AppleScript layers. root = Directory.systemTemp.createTempSync("xcode setup ' quotes "); apps = Directory('${root.path}/Applications')..createSync(); bin = Directory('${root.path}/bin')..createSync(); calls = File('${root.path}/calls'); - script('${bin.path}/id', 'echo "\${FIXTURE_UID:-1000}"'); - script('${bin.path}/uname', 'echo arm64'); - script('${bin.path}/PlistBuddy', r'cat "$3"'); - script('${bin.path}/sudo', r''' -# Exercise the real command sequence without invoking sudo or a real Xcode. + 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 "$@" @@ -55,52 +68,52 @@ if [ "$*" = 'install --help' ]; then 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', '27.0'); + xcode('Spare.app'); Directory('${apps.path}/Spare.app').renameSync('${root.path}/Spare.app'); }); tearDown(() => root.delete(recursive: true)); - Future runInstaller([Map env = const {}]) { - // Redirect ONLY native/system operations into the fixture. No real sudo, - // xcodes, Xcode, downloads, or Terminal windows are used by these tests. - var installer = setup.installerScript - .replaceAll( - '/Applications/Xcode*.app', - '${setup.shellQuote(apps.path)}/Xcode*.app', - ) - .replaceAll( - '--directory /Applications', - '--directory ${setup.shellQuote(apps.path)}', - ) - .replaceAll('[ ! -t 0 ]', '[ "\${FIXTURE_TTY:-1}" != 1 ]'); - for (final entry in { - '/usr/bin/id': 'id', - '/usr/bin/uname': 'uname', - '/usr/bin/sudo': 'sudo', - '/usr/libexec/PlistBuddy': 'PlistBuddy', - }.entries) { - installer = installer.replaceAll( - entry.key, - setup.shellQuote('${bin.path}/${entry.value}'), - ); + 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', + xcodes: xcodes ?? '${bin.path}/xcodes', + tools: tools, + interactive: interactive, + ); return Process.run( '/bin/bash', ['--noprofile', '--norc', '-c', installer], includeParentEnvironment: false, environment: { - 'HOME': root.path, - 'PATH': '${bin.path}:/usr/bin:/bin', 'CALLS': calls.path, + 'FIXTURE_APPS': apps.path, 'SPARE': '${root.path}/Spare.app', ...env, }, @@ -108,37 +121,47 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" } group('Xcode setup', () { - test('downloads with no sudo, then initializes only Xcode 27.0', () async { - xcode('Xcode.app', '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( - File('${apps.path}/Xcode.app/Contents/Info.plist').readAsStringSync(), - '26.0', - ); - }); + test( + 'downloads without sudo, then initializes only the release build', + () async { + xcode('Xcode.app', version: '26.0'); + xcode('Xcode-27.0.0-Beta.1.app', build: '27A5001a'); + 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('reuses a manual installation and retries unfinished setup', () async { - xcode("Xcode-27.0 user's.app", '27.0'); - File('${bin.path}/xcodes').deleteSync(); - final result = await runInstaller(); - expect(result.exitCode, 0, reason: '${result.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({'DOWNLOAD_EXIT': '42'}); + 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'))); @@ -147,93 +170,181 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" test( 'rejects xcodes without no-superuser support before downloading', () async { - final result = await runInstaller({'HELP_FLAG': 'old version'}); + final result = await runInstaller(env: {'HELP_FLAG': 'old version'}); expect(result.exitCode, isNot(0)); expect(calls.existsSync(), isFalse); }, ); - test('rejects a mismatched executable before sudo', () async { - xcode('Xcode-27.0.app', '27.0'); - final result = await runInstaller({'REPORTED_VERSION': '26.0'}); - expect(result.exitCode, isNot(0)); - expect(calls.existsSync(), isFalse); - }); + 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', '27.0'); - final result = await runInstaller({'SETUP_EXIT': '43'}); + 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'))); }); - for (final env in [ - {'FIXTURE_UID': '0'}, - {'FIXTURE_TTY': '0'}, - ]) { - test('rejects an unsafe session: ${env.keys.single}', () async { - final result = await runInstaller(env); - expect(result.exitCode, isNot(0)); - expect(calls.existsSync(), isFalse); - }); - } + 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( - 'launcher drops build environment and safely quotes account data', + 'resolves nonstandard PATH installations before cleaning the environment', () async { - final home = "${root.path}/\$(touch injected)"; - final command = setup - .terminalCommand(home: home, user: "user's name") - .replaceFirst( - setup.shellQuote(setup.installerScript), - setup.shellQuote('/usr/bin/env'), - ); - final result = await Process.run( - '/bin/sh', - ['-c', command], - workingDirectory: root.path, - includeParentEnvironment: false, - environment: { - 'BUILD_ONLY': 'fixture', - 'XCODES_PASSWORD': 'fixture', - 'DEVELOPER_DIR': '/wrong/Xcode.app', - 'SUDO_ASKPASS': '/wrong/helper', - 'BASH_ENV': '/wrong/startup', - }, - ); + 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, File(executable).resolveSymbolicLinksSync()); + expect(setup.findXcodes('.::relative'), isNull); + final result = await runInstaller(xcodes: resolved); expect(result.exitCode, 0, reason: '${result.stderr}'); - final output = result.stdout as String; - expect(output, contains('HOME=$home\n')); - expect(output, contains("USER=user's name\n")); - for (final name in [ - 'BUILD_ONLY', - 'XCODES_PASSWORD', - 'DEVELOPER_DIR', - 'SUDO_ASKPASS', - 'BASH_ENV', - ]) { - expect(output, isNot(contains('$name='))); - } - expect(File('${root.path}/injected').existsSync(), isFalse); + expect(calls.readAsLinesSync().first, 'download-without-sudo'); }, ); - test('AppleScript round-trips the command without executing it', () async { + 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', + if (File('/bin/tcsh').existsSync()) '/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='))); + } + }, + ); + } + + 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( - home: '/Users/a "quoted" \\ name\nnext line', - user: "user's name", + '/tmp/stack-xcode-setup-fixture/setup.sh', ); final appleScript = setup.terminalAppleScript(command); - final literal = appleScript - .split('\n')[2] - .substring(' do script '.length); - final result = await Process.run('/usr/bin/osascript', [ + if (!Platform.isMacOS) return; + // 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', - 'return $literal', + appleScript, ]); expect(result.exitCode, 0, reason: '${result.stderr}'); - expect(result.stdout, '$command\n'); - }, skip: !Platform.isMacOS); + }); + + test('completion waits for acknowledgement on a real terminal', () async { + // Use a fixture pseudo-terminal, never Terminal.app or native tools. + if (!Platform.isMacOS) return; + final probe = File('${root.path}/finish.sh') + ..writeAsStringSync( + setup.installerScript( + home: root.path, + user: 'fixture', + 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.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..daefb85e1d --- /dev/null +++ b/scripts/windows/build_secp256k1.dart @@ -0,0 +1,371 @@ +// 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); + +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, + }); + + final Directory root; + final bool nativeWindows; + final Map environment; + final CommandRunner run; + 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('x86_64-w64-mingw32-g++', ['--version']); + 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 { + 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', [ + 'clone', + '--no-checkout', + 'https://github.com/bitcoin-core/secp256k1.git', + 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 { + work.deleteSync(recursive: true); + } + } +} + +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..16bca5645c --- a/scripts/windows/build_secp256k1_wsl.sh +++ b/scripts/windows/build_secp256k1_wsl.sh @@ -1,15 +1,4 @@ -mkdir -p build -cd build -if [ ! -d "secp256k1" ]; then - git clone https://github.com/bitcoin-core/secp256k1 -fi -cd secp256k1 -git checkout 68b55209f1ba3e6c0417789598f5f75649e9c14c -git reset --hard -rm -rf build -mkdir -p build && cd build -cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/x86_64-w64-mingw32.toolchain.cmake -DSECP256K1_ENABLE_MODULE_RECOVERY=ON -cmake --build . -mkdir -p ../../../../../build -cp bin/libsecp256k1-2.dll "../../../../../build/secp256k1.dll" -cd ../../../ +#!/usr/bin/env bash +set -e +# Compatibility entry point; shares the native Windows source revision and recipe. +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..40553d20c9 --- /dev/null +++ b/tool/check_build_environment.dart @@ -0,0 +1,298 @@ +// Run directly with Dart before build_app.sh changes any generated files. +import 'dart:ffi'; +import 'dart:io'; + +import 'setup_xcode.dart' show xcodeVersion, xcodeBuild; + +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 +'''; + +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; +} + +Future> checkBuildEnvironment({ + required BuildEnvironment host, + required String platform, + required String app, + required String mode, + String? wslDistribution, +}) 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 errors = []; + final home = host.variables[host.os == 'windows' ? 'USERPROFILE' : 'HOME']; + final separator = host.os == 'windows' ? r'\' : '/'; + var hasRustup = false; + for (final executable in [ + 'rustup', + if (home != null) '$home$separator.cargo${separator}bin${separator}rustup', + ]) { + if ((await host.command(executable, ['--version']))?.exitCode == 0) { + hasRustup = true; + break; + } + } + if (!hasRustup) { + final xelis = app == 'stack_wallet' + ? ' Stack Wallet also needs Rust 1.99.1 for Xelis.' + : ''; + errors.add( + 'Install Rustup on the Flutter host (https://rustup.rs/). ' + 'Every app needs Rust 1.90.0 for DNSSEC, including with -d.$xelis ' + 'The hooks install their pinned toolchains automatically.', + ); + } + if (mode == 'prebuilt') return errors; + + // Legacy configuration under WSL still invokes native Windows Flutter later. + // Its hooks select the default/requested distro, which can differ from this + // shell's current WSL distribution. Probe through wsl.exe in that case too. + final windowsFromWsl = + platform == 'windows' && + host.os == 'linux' && + (host.variables['WSL_DISTRO_NAME']?.isNotEmpty ?? false); + 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 selected = await selectedDnixXcode(host); + if (selected == null) { + errors.add( + 'Source mode needs Xcode $xcodeVersion ($xcodeBuild) under ' + '/Applications. Run dart tool/setup_xcode.dart, or use ' + 'build_app.sh -d for verified dnix prebuilts.', + ); + } else if (selected.build != xcodeBuild) { + errors.add( + 'dnix would select Xcode $xcodeVersion (${selected.build}) ' + 'at ${selected.directory}, rather than release $xcodeBuild. ' + '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, +) 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 version = await host.command( + '/usr/bin/xcodebuild', + ['-version'], + environment: {...environment, 'DEVELOPER_DIR': candidate}, + ); + if (version?.exitCode != 0) continue; + final lines = '${version!.stdout}'.trim().split(RegExp(r'\r?\n')); + if (lines.isEmpty || lines.first.trim() != 'Xcode $xcodeVersion') continue; + return ( + directory: candidate, + build: lines.length > 1 + ? lines[1].trim().replaceFirst('Build version ', '') + : 'unknown', + ); + } + return null; +} + +Future main(List args) async { + if (args.length < 3 || args.length > 4) { + stderr.writeln( + 'Usage: dart tool/check_build_environment.dart ' + ' [wslDistribution]', + ); + exitCode = 64; + return; + } + try { + final errors = await checkBuildEnvironment( + host: BuildEnvironment.current(), + platform: args[0], + app: args[1], + mode: args[2], + wslDistribution: args.length == 4 ? args[3] : null, + ); + 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/configure_native_assets.dart b/tool/configure_native_assets.dart new file mode 100644 index 0000000000..cb50faa4eb --- /dev/null +++ b/tool/configure_native_assets.dart @@ -0,0 +1,55 @@ +// Plain Dart so configuration does not require pub get or run native hooks. +import 'dart:convert'; +import 'dart:io'; + +String configureNativeAssets( + String template, + String mode, + String? distribution, +) { + if (!['source', 'prebuilt'].contains(mode)) { + throw ArgumentError.value(mode, 'mode', 'Expected source or prebuilt'); + } + final selectedDistribution = distribution?.trim(); + if (distribution != null && + (selectedDistribution!.isEmpty || + selectedDistribution.startsWith('-') || + distribution.contains(RegExp(r'[\x00-\x1f\x7f]')))) { + throw ArgumentError.value(distribution, 'distribution', 'Invalid WSL name'); + } + 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/setup_xcode.dart b/tool/setup_xcode.dart index 4296a0247c..e02c8826ec 100644 --- a/tool/setup_xcode.dart +++ b/tool/setup_xcode.dart @@ -2,55 +2,129 @@ import 'dart:io'; const xcodeVersion = '27.0'; +// Apple Developer Releases, September 14, 2026: +// https://developer.apple.com/news/releases/ +const xcodeBuild = '27A266a'; -// This runs only in the new Terminal session, never in the build process. -// Keep privileged operations explicit and use sudo -k WITH a command: sudo -// prompts itself and neither reads nor updates its cached credentials. -const installerScript = r''' +// 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, + 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(xcodeVersion)} +XCODE_BUILD=${shellQuote(xcodeBuild)} +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 [ "$(/usr/bin/id -u)" = 0 ]; then +if [ "$("$ID" -u)" = 0 ]; then echo 'Run this helper as your normal macOS user, without sudo.' >&2 exit 1 fi -if [ "$(/usr/bin/uname -m)" != arm64 ]; then +if [ "$("$UNAME" -m)" != arm64 ]; then echo 'This setup helper requires an Apple Silicon Mac.' >&2 exit 1 fi -if [ ! -t 0 ]; then +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 - for candidate in /Applications/Xcode*.app; do + local candidate version build + for candidate in "$APPLICATIONS"/Xcode*.app; do [ -f "$candidate/Contents/Info.plist" ] || continue - version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \ + version=$("$PLIST_BUDDY" -c 'Print :CFBundleShortVersionString' \ "$candidate/Contents/Info.plist" 2>/dev/null) || continue - case "$version" in - 27.0|27.0.0) XCODE_APP="$candidate"; return 0 ;; - esac + build=$("$PLIST_BUDDY" -c 'Print :ProductBuildVersion' \ + "$candidate/Contents/version.plist" 2>/dev/null) || continue + if [ "$version" = "$XCODE_VERSION" ] && [ "$build" = "$XCODE_BUILD" ]; then + XCODE_APP="$candidate" + return 0 + fi done return 1 } if ! find_xcode; then - if ! command -v xcodes >/dev/null 2>&1; then - echo 'Install xcodes first: brew install xcodesorg/made/xcodes' >&2 + 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 - help=$(xcodes install --help) + help=$("$XCODES" install --help) if [[ "$help" != *--no-superuser* ]]; then - echo 'Update xcodes: brew upgrade xcodesorg/made/xcodes' >&2 + echo 'Update xcodes: this helper requires --no-superuser support.' >&2 exit 1 fi - # xcodes handles Apple account authentication. It never receives sudo's password. - xcodes install 27.0 --directory /Applications --no-superuser --update + # xcodes handles Apple account authentication, never sudo's password. + "$XCODES" install "$XCODE_VERSION" --directory "$APPLICATIONS" --no-superuser --update if ! find_xcode; then - echo 'Installation did not produce Xcode 27.0 under /Applications.' >&2 + printf 'Installation did not produce Xcode %s (%s) under %s.\n' \ + "$XCODE_VERSION" "$XCODE_BUILD" "$APPLICATIONS" >&2 exit 1 fi fi @@ -62,66 +136,82 @@ if [ ! -x "$xcodebuild" ]; then exit 1 fi version=$("$xcodebuild" -version) -if [ "${version%%$'\n'*}" != 'Xcode 27.0' ]; then +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.' -# An absolute executable, no root shell, no -S, no sudo -v, no cached ticket. -/usr/bin/sudo -k "$xcodebuild" -runFirstLaunch +# -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 'In your build terminal, use:' +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 shellQuote(String value) => "'${value.replaceAll("'", "'\\''")}'"; +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) { + return candidate.resolveSymbolicLinksSync(); + } + } on FileSystemException { + // Keep searching if an earlier PATH entry is inaccessible. + } + } + return null; +} -String terminalCommand({required String home, required String user}) { - // Do not forward PATH, BASH_ENV, shell options, Apple account credentials, - // or any other environment from the caller/build. No shell startup files. - final command = [ - '/usr/bin/env', - '-i', - 'HOME=$home', - 'USER=$user', - 'LOGNAME=$user', - 'PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', - 'TERM=xterm-256color', - '/bin/bash', - '--noprofile', - '--norc', - '-c', - installerScript, - ]; - // Replace the new terminal's shell so it cannot become a later build shell. - return 'exec ${command.map(shellQuote).join(' ')}'; +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) { - final quoted = command - .replaceAll(r'\', r'\\') - .replaceAll('"', r'\"') - .replaceAll('\r', r'\r') - .replaceAll('\n', r'\n'); - // No "in window/tab": Terminal creates a new session with its own TTY. + 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' - ' do script "$quoted"\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 Xcode $xcodeVersion in a separate Terminal window using xcodes. +Install/setup Xcode $xcodeVersion ($xcodeBuild) in a separate Terminal window. +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 Terminal command for review without executing it.'''); +--print prints the installer script for review without executing it.'''); return; } if (args.isNotEmpty && !(args.length == 1 && args.single == '--print')) { @@ -136,9 +226,9 @@ Apple first-launch setup accepts the Xcode license. No build is launched. exitCode = 1; return; } - final command = terminalCommand(home: home, user: user); + final xcodes = findXcodes(Platform.environment['PATH']); if (args.isNotEmpty) { - stdout.writeln(command); + stdout.writeln(installerScript(home: home, user: user, xcodes: xcodes)); return; } if (!Platform.isMacOS) { @@ -154,18 +244,53 @@ Apple first-launch setup accepts the Xcode license. No build is launched. exitCode = 1; return; } - final launch = await Process.run('/usr/bin/osascript', [ - '-e', - terminalAppleScript(command), - ], includeParentEnvironment: false); - if (launch.exitCode != 0) { - stderr.writeln('Could not open Terminal: ${launch.stderr}'); - exitCode = launch.exitCode; + // 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, + 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. This launcher does not ' - 'receive passwords or report installation success.', + 'and wait for "Setup complete" before building. Press Return there after ' + 'copying the instructions. This launcher does not receive passwords or ' + 'report installation success.', ); } From df7f5f541c7cbf019fd139056752d7d3534c5540 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 10 Nov 2026 16:19:20 -0600 Subject: [PATCH 04/13] Harden native dependency setup after review Preserve Windows build results when cleanup fails, remove unused Docker Rust toolchain layers, and reject package-local dnix mode overrides on the actual Flutter host. Read Xcode requirements from pinned dependency metadata with revision-specific compatibility for packages missing build pins. Share WSL validation, check dependency contracts, simplify hook settings, and update setup guidance. Validation: Dart analysis and formatting, Bash syntax, actionlint, and git diff --check. Final changes were validated statically only; new regression tests and native Windows/Build workflow execution remain pending. Upstream Xcode build-number pins are still needed. --- .github/workflows/build.yaml | 60 +++-- .github/workflows/test.yaml | 9 +- Dockerfile | 49 ++-- docs/building.md | 56 +++- .../templates/pubspec.template.yaml | 34 +-- scripts/build_app.sh | 4 + scripts/tests/build_environment_test.dart | 145 +++++++++- .../tests/configure_native_assets_test.dart | 68 +++++ scripts/tests/native_build_config_test.dart | 168 ++++++------ .../native_dependency_contract_test.dart | 255 ++++++++++++++++++ .../tests/windows_native_overrides_test.dart | 102 +++++++ scripts/tests/windows_secp_build_test.dart | 152 ++++++++++- scripts/tests/xcode_setup_test.dart | 161 +++++++---- scripts/windows/build_secp256k1.dart | 29 +- scripts/windows/build_secp256k1_wsl.sh | 12 + tool/check_build_environment.dart | 143 ++++++---- tool/check_native_overrides.dart | 116 ++++++++ tool/configure_native_assets.dart | 10 +- tool/native_build_options.dart | 14 + tool/native_dependency_compatibility.json | 9 + tool/native_dependency_contract.dart | 216 +++++++++++++++ tool/setup_xcode.dart | 96 +++++-- 22 files changed, 1604 insertions(+), 304 deletions(-) create mode 100644 scripts/tests/configure_native_assets_test.dart create mode 100644 scripts/tests/native_dependency_contract_test.dart create mode 100644 scripts/tests/windows_native_overrides_test.dart create mode 100644 tool/check_native_overrides.dart create mode 100644 tool/native_build_options.dart create mode 100644 tool/native_dependency_compatibility.json create mode 100644 tool/native_dependency_contract.dart diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6ab59ced21..933d615dbb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -77,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: @@ -144,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: @@ -279,7 +283,9 @@ jobs: done - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_wallet - name: Decode secrets env: @@ -348,7 +354,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: @@ -417,7 +425,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: @@ -480,7 +490,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: @@ -547,7 +559,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: @@ -731,7 +745,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: @@ -858,7 +874,9 @@ jobs: done - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart campfire - name: Decode secrets env: @@ -927,7 +945,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: @@ -996,7 +1016,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: @@ -1176,7 +1198,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: @@ -1303,7 +1327,9 @@ jobs: done - name: Get dependencies - run: flutter pub get + run: | + flutter pub get + dart tool/check_native_overrides.dart stack_duo - name: Decode secrets env: @@ -1372,7 +1398,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: @@ -1441,7 +1469,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 87b2486915..9cd28caeb1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -66,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: @@ -107,6 +109,11 @@ jobs: 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 diff --git a/Dockerfile b/Dockerfile index a38b6a7638..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 toolchain install 1.90.0 --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,11 +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 or OpenCV/OpenCL; -# pinned Rust toolchains for DNSSEC and Xelis, with Android targets. +# Rust toolchains and targets are installed by hooks at runtime. FROM ubuntu:24.04 AS android ENV DEBIAN_FRONTEND=noninteractive \ @@ -114,18 +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 toolchain install 1.90.0 --profile minimal \ - && 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 @@ -183,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) @@ -204,16 +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/* -# dnix uses prebuilts; all apps need Rust 1.90.0 for DNSSEC, plus -# Rust 1.99.1 for Stack Wallet's Xelis 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 \ - && rustup toolchain install 1.90.0 --profile minimal \ - && 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 @@ -233,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 feefb5842b..349321b075 100644 --- a/docs/building.md +++ b/docs/building.md @@ -29,7 +29,8 @@ 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 an entry for each enabled package: +The generated settings look like this, with entries for all four packages. +Settings for packages absent from the selected app are ignored: ```yaml hooks: @@ -40,9 +41,14 @@ hooks: Rerun `build_app.sh` to switch modes. It regenerates `pubspec.yaml`, so keep permanent changes in `scripts/app_config/templates/pubspec.template.yaml`. -Remove any package-local `.dnix-build` development override when checking a -release: dnix gives that file precedence over the app's settings, and `dev` -mode skips hash verification. See [dnix build modes](https://github.com/cypherstack/dnix/blob/main/doc/configuration.md#build-modes). +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). ### Which tools are still required? @@ -97,7 +103,10 @@ sudo apt-get install meson libgirepository1.0-dev valac xsltproc \ 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. +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. ### Android @@ -139,16 +148,29 @@ 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, or Nix installations. It selects the **27A266a** release -build and ignores 27.0 betas. The setup window waits for Return after displaying -its result, so you can copy the instructions even with a close-on-exit profile. +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. `xcodes` may remember Apple account credentials in your -Keychain according to its own settings. +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 @@ -269,10 +291,15 @@ 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. -If configuring from the legacy Linux/WSL shell, keep its Linux Dart/Rustup tools -as well as Rustup on the native Windows Flutter host. The prerequisite check -can verify Rustup only for the Dart host running configuration. Windows builds -from WSL still probe the WSL distribution chosen for the native Windows hooks. +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. ## Configure and build @@ -293,6 +320,7 @@ All four configuration arguments are required: ```sh bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s flutter pub get +dart tool/check_native_overrides.dart stack_wallet flutter build linux --release ``` diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 65b9d189d6..8e648fb79c 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -368,29 +368,21 @@ dependency_overrides: # 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. -# The app's existing dependency markers also enable its hook configuration. +# Settings for packages absent from an app's dependency graph are ignored. hooks: user_defines: -# %%ENABLE_FROST%% -# frostdart: -# build: PLACEHOLDER_DNIX_BUILD_MODE -# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION -# %%END_ENABLE_FROST%% -# %%ENABLE_TOR%% -# tor_ffi_plugin: -# build: PLACEHOLDER_DNIX_BUILD_MODE -# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION -# %%END_ENABLE_TOR%% -# %%ENABLE_EPIC%% -# flutter_libepiccash: -# build: PLACEHOLDER_DNIX_BUILD_MODE -# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION -# %%END_ENABLE_EPIC%% -# %%ENABLE_MWC%% -# flutter_libmwc: -# build: PLACEHOLDER_DNIX_BUILD_MODE -# wsl_distribution: PLACEHOLDER_DNIX_WSL_DISTRIBUTION -# %%END_ENABLE_MWC%% + 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 ed8c7deafb..54fa4335e5 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -102,6 +102,7 @@ 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") @@ -140,6 +141,9 @@ 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, tor_ffi_plugin) are native-assets # packages now; their build hooks compile (or fetch) them during flutter build. diff --git a/scripts/tests/build_environment_test.dart b/scripts/tests/build_environment_test.dart index fa275d1f30..9ffad3ece7 100644 --- a/scripts/tests/build_environment_test.dart +++ b/scripts/tests/build_environment_test.dart @@ -4,13 +4,22 @@ import 'dart:io'; import 'package:test/test.dart'; import '../../tool/check_build_environment.dart'; -import '../../tool/setup_xcode.dart' show xcodeBuild; +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; @@ -18,7 +27,7 @@ class HostFixture { final applications = ['/Applications/Xcode.app']; final versions = { '/Applications/Xcode.app/Contents/Developer': - 'Xcode 27.0\nBuild version $xcodeBuild\n', + 'Xcode ${fixtureXcode.version}\nBuild version ${fixtureXcode.build}\n', }; final calls = < @@ -56,6 +65,18 @@ class HostFixture { 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, ''); } @@ -72,12 +93,16 @@ class HostFixture { 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, ); } @@ -200,6 +225,7 @@ void main() { 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', @@ -211,9 +237,72 @@ void main() { 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); @@ -266,6 +355,58 @@ void main() { 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( 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 index e68cb99769..e30fa5774d 100644 --- a/scripts/tests/native_build_config_test.dart +++ b/scripts/tests/native_build_config_test.dart @@ -7,20 +7,17 @@ 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 packages = { - 'stack_wallet': { - 'frostdart', - 'tor_ffi_plugin', - 'flutter_libepiccash', - 'flutter_libmwc', - }, - 'stack_duo': {'frostdart', 'tor_ffi_plugin'}, - 'campfire': {'tor_ffi_plugin'}, + const apps = ['stack_wallet', 'stack_duo', 'campfire']; + const allPackages = { + 'frostdart', + 'tor_ffi_plugin', + 'flutter_libepiccash', + 'flutter_libmwc', }; - final allPackages = packages['stack_wallet']!; late Directory root; late Directory bin; @@ -58,14 +55,15 @@ void main() { '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 packages.keys) 'scripts/app_config/configure_$app.sh', + 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 packages.keys) { + for (final app in apps) { final icon = File( p.join( root.path, @@ -96,20 +94,27 @@ pwd > "\$APP_PROJECT_ROOT_DIR/native-cwd" test "$1" = log printf '0000000000000000000000000000000000000000\n' '''); - // Invoke this SDK directly: Flutter's dart launcher also calls git/uname, - // which are simulated below to exercise the Windows dispatch on POSIX. + // Invoke this SDK directly; Flutter's launcher also calls our fake git. final dart = Platform.resolvedExecutable.replaceAll("'", "'\\''"); script('bin/dart', "exec '$dart' \"\$@\""); - script('bin/uname', r''' -if [ -n "${DNIX_TEST_HOST:-}" ]; then - printf '%s\n' "$DNIX_TEST_HOST" -else - exec /usr/bin/uname "$@" -fi -'''); 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'; @@ -154,12 +159,15 @@ void main(List args) { bool prebuilt = false, bool skipNative = true, String platform = 'macos', - String? host, 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', [ @@ -180,7 +188,6 @@ void main(List args) { environment: { 'PATH': '${bin.path}:${Platform.environment['PATH']}', 'DNIX_WSL_DISTRIBUTION': '', - if (host != null) 'DNIX_TEST_HOST': host, ...environment, }, ); @@ -190,24 +197,26 @@ void main(List args) { 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 app, - String mode, { - String? distribution, - bool linux = false, - }) { + 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 dependencies = pubspec['dependencies'] as YamlMap; final defines = (pubspec['hooks'] as YamlMap)['user_defines'] as YamlMap; - expect( - dependencies.keys.where(allPackages.contains).toSet(), - packages[app], - ); - expect(defines.keys.toSet(), packages[app]); - for (final package in packages[app]!) { + expect(defines.keys.toSet(), allPackages); + for (final package in allPackages) { expect(defines[package], { 'build': mode, if (distribution != null) 'wsl_distribution': distribution, @@ -228,43 +237,40 @@ void main(List args) { } group('dnix configuration', () { - for (final app in packages.keys) { + 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(app, prebuilt ? 'prebuilt' : 'source'); + expectMode(prebuilt ? 'prebuilt' : 'source'); } }, ); } - for (final host in ['MINGW64_NT', 'Linux']) { - for (final prebuilt in [false, true]) { - test( - 'Windows config builds secp in project root on $host, -d=$prebuilt', - () async { - await configure( - 'stack_wallet', - platform: 'windows', - host: host, - skipNative: false, - prebuilt: prebuilt, - fromScripts: true, - ); - expectMode('stack_wallet', 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( + '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]) { @@ -275,11 +281,7 @@ void main(List args) { skipNative: false, prebuilt: prebuilt, ); - expectMode( - 'stack_wallet', - prebuilt ? 'prebuilt' : 'source', - linux: true, - ); + expectMode(prebuilt ? 'prebuilt' : 'source', linux: true); expect(File(p.join(root.path, 'linux-deps')).readAsLinesSync(), [ 'secure_storage_deps', 'secp256k1', @@ -320,11 +322,7 @@ void main(List args) { prebuilt: prebuilt, environment: {'DNIX_WSL_DISTRIBUTION': distro}, ); - expectMode( - 'stack_wallet', - prebuilt ? 'prebuilt' : 'source', - distribution: distro, - ); + expectMode(prebuilt ? 'prebuilt' : 'source', distribution: distro); expect( jsonDecode( File(p.join(root.path, 'preflight-args')).readAsStringSync(), @@ -337,13 +335,14 @@ void main(List args) { distribution: 'Ubuntu-24.04', environment: {'DNIX_WSL_DISTRIBUTION': distro}, ); - expectMode('stack_wallet', 'source', distribution: 'Ubuntu-24.04'); + expectMode('source', distribution: 'Ubuntu-24.04'); await configure('stack_wallet'); - expectMode('stack_wallet', 'source'); + 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), ]) { @@ -368,6 +367,25 @@ void main(List args) { ); } + 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), 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/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 index d16425b122..df37427819 100644 --- a/scripts/tests/windows_secp_build_test.dart +++ b/scripts/tests/windows_secp_build_test.dart @@ -1,5 +1,6 @@ // 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'; @@ -16,6 +17,37 @@ void main() { 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 [ @@ -46,6 +78,7 @@ void main() { 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': @@ -66,6 +99,12 @@ void main() { 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]) => @@ -92,9 +131,9 @@ void main() { final operation = args.first; if (operation == failAt) return result('', 42); if (executable == 'git') { - if (operation == 'clone') { + if (operation == 'init') { Directory(args.last).createSync(recursive: true); - } else if (operation != 'checkout') { + } else if (operation != 'checkout' && operation != 'fetch') { fail('Unexpected git invocation: $args'); } return result(); @@ -123,7 +162,7 @@ void main() { test('preflight discovers VS CMake without creating build outputs', () async { await builder().check(); expect(Directory('${root.path}/build').existsSync(), isFalse); - expect(operation('clone'), isEmpty); + expect(operation('fetch'), isEmpty); expect(calls.any((c) => c.executable == 'cmake'), isFalse); expect( calls.any( @@ -139,7 +178,7 @@ void main() { Directory(sdkRoot).deleteSync(recursive: true); await expectLater(builder().check(), throwsA(isA())); expect(Directory('${root.path}/build').existsSync(), isFalse); - expect(operation('clone'), isEmpty); + expect(operation('fetch'), isEmpty); }); test('incomplete x64 SDK is rejected', () async { @@ -171,6 +210,14 @@ void main() { '--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/'), @@ -207,13 +254,14 @@ void main() { isTrue, ); expect(File('${root.path}/build/secp256k1.dll').existsSync(), isTrue); + expect(calls.any((c) => c.executable.contains('mingw32-g++')), isFalse); }); - test('matching receipt avoids cloning and recompiling', () async { + test('matching receipt avoids fetching and recompiling', () async { await builder().build(); calls.clear(); await builder().build(); - expect(operation('clone'), isEmpty); + expect(operation('fetch'), isEmpty); expect(operation('--build'), isEmpty); }); @@ -222,7 +270,7 @@ void main() { File('${root.path}/build/secp256k1.dll').writeAsStringSync('corrupted'); calls.clear(); await builder().build(); - expect(operation('clone'), hasLength(1)); + expect(operation('fetch'), hasLength(1)); expect( File('${root.path}/build/secp256k1.dll').readAsStringSync(), 'fixture Windows library', @@ -233,12 +281,12 @@ void main() { await builder().build(); calls.clear(); await builder(nativeWindows: false).build(); - expect(operation('clone'), hasLength(1)); + 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('clone'), hasLength(1)); + expect(operation('fetch'), hasLength(1)); }); test('changed CMake identity invalidates the cache', () async { @@ -246,7 +294,7 @@ void main() { cmakeVersion = 'cmake version 4.1.0'; calls.clear(); await builder().build(); - expect(operation('clone'), hasLength(1)); + expect(operation('fetch'), hasLength(1)); }); test('new SDK version invalidates cache and is selected for CMake', () async { @@ -254,7 +302,7 @@ void main() { installSdk('10.0.26100.0'); calls.clear(); await builder().build(); - expect(operation('clone'), hasLength(1)); + expect(operation('fetch'), hasLength(1)); expect( operation('-S').single.args, contains('-DCMAKE_SYSTEM_VERSION=10.0.26100.0'), @@ -266,10 +314,10 @@ void main() { compilerFlags = {'CL': '/O1', 'LDFLAGS': '-s'}; calls.clear(); await builder().build(); - expect(operation('clone'), hasLength(1)); + expect(operation('fetch'), hasLength(1)); }); - for (final operation in ['clone', 'checkout', '-S', '--build']) { + for (final operation in ['init', 'fetch', 'checkout', '-S', '--build']) { test( '$operation failure propagates without publishing a receipt', () async { @@ -292,4 +340,82 @@ void main() { 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 index 98fd7ec26d..c08bcc0347 100644 --- a/scripts/tests/xcode_setup_test.dart +++ b/scripts/tests/xcode_setup_test.dart @@ -6,6 +6,8 @@ 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; @@ -21,8 +23,8 @@ void main() { void xcode( String name, { - String version = setup.xcodeVersion, - String build = setup.xcodeBuild, + String version = fixtureVersion, + String build = fixtureBuild, }) { final contents = '${apps.path}/$name/Contents'; Directory(contents).createSync(recursive: true); @@ -65,6 +67,7 @@ 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 @@ -103,6 +106,8 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" final installer = setup.installerScript( home: root.path, user: 'fixture', + version: fixtureVersion, + build: fixtureBuild, xcodes: xcodes ?? '${bin.path}/xcodes', tools: tools, interactive: interactive, @@ -125,7 +130,6 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" 'downloads without sudo, then initializes only the release build', () async { xcode('Xcode.app', version: '26.0'); - xcode('Xcode-27.0.0-Beta.1.app', build: '27A5001a'); final result = await runInstaller(); expect(result.exitCode, 0, reason: '${result.stderr}'); expect(calls.readAsLinesSync(), [ @@ -143,6 +147,31 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" }, ); + 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 { @@ -176,6 +205,14 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" }, ); + 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'}, @@ -219,7 +256,7 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" final executable = '${directory.path}/xcodes'; File('${bin.path}/xcodes').copySync(executable); final resolved = setup.findXcodes('.:${directory.path}:/missing'); - expect(resolved, File(executable).resolveSymbolicLinksSync()); + expect(resolved, executable); expect(setup.findXcodes('.::relative'), isNull); final result = await runInstaller(xcodes: resolved); expect(result.exitCode, 0, reason: '${result.stderr}'); @@ -227,6 +264,29 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" }, ); + 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 { @@ -237,10 +297,7 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" }, ); - for (final shell in [ - '/bin/bash', - if (File('/bin/tcsh').existsSync()) '/bin/tcsh', - ]) { + for (final shell in ['/bin/bash', '/bin/tcsh']) { test( 'short launcher works with $shell and removes the build environment', () async { @@ -273,6 +330,7 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" expect(output, isNot(contains('$name='))); } }, + skip: !File(shell).existsSync() ? '$shell is not installed' : false, ); } @@ -294,7 +352,6 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" '/tmp/stack-xcode-setup-fixture/setup.sh', ); final appleScript = setup.terminalAppleScript(command); - if (!Platform.isMacOS) return; // Compile only: no Terminal window or privileged command runs. final output = '${root.path}/setup.scpt'; final result = await Process.run('/usr/bin/osacompile', [ @@ -304,47 +361,55 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" 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. - if (!Platform.isMacOS) return; - final probe = File('${root.path}/finish.sh') - ..writeAsStringSync( - setup.installerScript( - home: root.path, - user: 'fixture', - tools: tools, - interactive: true, - ), + 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}, ); - 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 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(); } - }); - 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.dart b/scripts/windows/build_secp256k1.dart index daefb85e1d..5f2f0ac859 100644 --- a/scripts/windows/build_secp256k1.dart +++ b/scripts/windows/build_secp256k1.dart @@ -16,6 +16,8 @@ Future _run( 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; @@ -29,12 +31,14 @@ class WindowsSecpBuilder { 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; @@ -195,7 +199,6 @@ class WindowsSecpBuilder { compiler = (await checked('x86_64-w64-mingw32-gcc', [ '--version', ])).stdout.toString(); - await checked('x86_64-w64-mingw32-g++', ['--version']); await checked('make', ['--version']); } final bundledCmake = @@ -240,6 +243,8 @@ class WindowsSecpBuilder { } 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'); @@ -268,12 +273,15 @@ class WindowsSecpBuilder { try { final source = '${work.path}/source'; final buildDir = '$source/build'; + await checked('git', ['init', source]); await checked('git', [ - 'clone', - '--no-checkout', + 'fetch', + '--depth', + '1', + '--no-tags', 'https://github.com/bitcoin-core/secp256k1.git', - source, - ]); + secpRevision, + ], workingDirectory: source); await checked('git', [ 'checkout', '--detach', @@ -332,7 +340,16 @@ class WindowsSecpBuilder { 'Built ${output.path} from secp256k1 v0.5.0 ($secpRevision).', ); } finally { - work.deleteSync(recursive: true); + 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.', + ); + } } } } diff --git a/scripts/windows/build_secp256k1_wsl.sh b/scripts/windows/build_secp256k1_wsl.sh index 16bca5645c..e0790f60f9 100755 --- a/scripts/windows/build_secp256k1_wsl.sh +++ b/scripts/windows/build_secp256k1_wsl.sh @@ -1,4 +1,16 @@ #!/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 +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 index 40553d20c9..873bad64dc 100644 --- a/tool/check_build_environment.dart +++ b/tool/check_build_environment.dart @@ -2,7 +2,8 @@ import 'dart:ffi'; import 'dart:io'; -import 'setup_xcode.dart' show xcodeVersion, xcodeBuild; +import 'native_build_options.dart'; +import 'native_dependency_contract.dart'; typedef CommandRunner = Future Function( String executable, @@ -81,26 +82,13 @@ fi nix --version '''; -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; -} - 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) || @@ -108,38 +96,70 @@ Future> checkBuildEnvironment({ 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 = []; - final home = host.variables[host.os == 'windows' ? 'USERPROFILE' : 'HOME']; - final separator = host.os == 'windows' ? r'\' : '/'; + // 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; - 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 (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 the Flutter host (https://rustup.rs/). ' + '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.', + 'The hooks install their pinned toolchains automatically.' + '$interoperability', ); } if (mode == 'prebuilt') return errors; - // Legacy configuration under WSL still invokes native Windows Flutter later. - // Its hooks select the default/requested distro, which can differ from this - // shell's current WSL distribution. Probe through wsl.exe in that case too. - final windowsFromWsl = - platform == 'windows' && - host.os == 'linux' && - (host.variables['WSL_DISTRO_NAME']?.isNotEmpty ?? false); + // 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', @@ -204,17 +224,23 @@ Future> checkBuildEnvironment({ if (host.os != 'macos') { errors.add('Apple targets require a macOS build host.'); } else if (host.architecture == 'arm64') { - final selected = await selectedDnixXcode(host); + 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 $xcodeVersion ($xcodeBuild) under ' + '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 != xcodeBuild) { + } else if (selected.build != requirement.build) { errors.add( - 'dnix would select Xcode $xcodeVersion (${selected.build}) ' - 'at ${selected.directory}, rather than release $xcodeBuild. ' + '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 ' @@ -230,8 +256,10 @@ Future> checkBuildEnvironment({ /// 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, -) async { + 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', [ @@ -249,20 +277,19 @@ Future<({String directory, String build})?> selectedDnixXcode( // The error below identifies the missing usable Xcode. } for (final candidate in candidates.toSet()) { - final version = await host.command( + final result = await host.command( '/usr/bin/xcodebuild', ['-version'], environment: {...environment, 'DEVELOPER_DIR': candidate}, ); - if (version?.exitCode != 0) continue; - final lines = '${version!.stdout}'.trim().split(RegExp(r'\r?\n')); - if (lines.isEmpty || lines.first.trim() != 'Xcode $xcodeVersion') continue; - return ( - directory: candidate, - build: lines.length > 1 - ? lines[1].trim().replaceFirst('Build version ', '') - : 'unknown', - ); + 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; } @@ -277,12 +304,28 @@ Future main(List args) async { 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) { 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 index cb50faa4eb..9489174b26 100644 --- a/tool/configure_native_assets.dart +++ b/tool/configure_native_assets.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:io'; +import 'native_build_options.dart'; + String configureNativeAssets( String template, String mode, @@ -10,13 +12,7 @@ String configureNativeAssets( if (!['source', 'prebuilt'].contains(mode)) { throw ArgumentError.value(mode, 'mode', 'Expected source or prebuilt'); } - final selectedDistribution = distribution?.trim(); - if (distribution != null && - (selectedDistribution!.isEmpty || - selectedDistribution.startsWith('-') || - distribution.contains(RegExp(r'[\x00-\x1f\x7f]')))) { - throw ArgumentError.value(distribution, 'distribution', 'Invalid WSL name'); - } + final selectedDistribution = validatedDistribution(distribution); return template .split('\n') .where( 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 index e02c8826ec..11293f5f75 100644 --- a/tool/setup_xcode.dart +++ b/tool/setup_xcode.dart @@ -1,10 +1,7 @@ // Run directly with `dart tool/setup_xcode.dart`; no pub get/build hooks needed. import 'dart:io'; -const xcodeVersion = '27.0'; -// Apple Developer Releases, September 14, 2026: -// https://developer.apple.com/news/releases/ -const xcodeBuild = '27A266a'; +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. @@ -28,6 +25,8 @@ 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, @@ -48,8 +47,8 @@ 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(xcodeVersion)} -XCODE_BUILD=${shellQuote(xcodeBuild)} +XCODE_VERSION=${shellQuote(version)} +XCODE_BUILD=${shellQuote(build)} APPLICATIONS=${shellQuote(tools.applications)} ID=${shellQuote(tools.id)} UNAME=${shellQuote(tools.uname)} @@ -94,28 +93,47 @@ 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) || continue - if [ "$version" = "$XCODE_VERSION" ] && [ "$build" = "$XCODE_BUILD" ]; then + "$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 - help=$("$XCODES" install --help) + 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 @@ -164,7 +182,9 @@ String? findXcodes(String? searchPath) { try { final stat = candidate.statSync(); if (stat.type == FileSystemEntityType.file && stat.mode & 0x49 != 0) { - return candidate.resolveSymbolicLinksSync(); + // 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. @@ -207,7 +227,7 @@ String terminalAppleScript(String command) { 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 Xcode $xcodeVersion ($xcodeBuild) in a separate Terminal window. +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. @@ -226,24 +246,54 @@ Apple first-launch setup accepts the Xcode license. No build is launched. exitCode = 1; return; } - final xcodes = findXcodes(Platform.environment['PATH']); - if (args.isNotEmpty) { - stdout.writeln(installerScript(home: home, user: user, xcodes: xcodes)); - return; - } - if (!Platform.isMacOS) { + final printOnly = args.isNotEmpty; + if (!printOnly && !Platform.isMacOS) { stderr.writeln('Xcode setup requires macOS.'); exitCode = 1; return; } - 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.'); + 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; @@ -255,6 +305,8 @@ Apple first-launch setup accepts the Xcode license. No build is launched. installerScript( home: home, user: user, + version: requirement.version, + build: requirement.build, xcodes: xcodes, cleanupDirectory: directory.path, ), From d6e08740a66fe543a573be441c89007c796a6569 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 10 Nov 2026 16:36:13 -0600 Subject: [PATCH 05/13] Preserve CMD switches in Git Bash build scripts Exclude uppercase /C and mklink /D from MSYS argument conversion per command, preserving existing user exclusions. Apply the same fix to Windows CI asset cleanup. Keep native path conversion and failure handling in place. Validation: Bash syntax, actionlint, whitespace checks, and independent static review. Native Windows execution remains unverified; no changes to dnix lock handling. --- .github/workflows/build.yaml | 9 ++++++--- scripts/app_config/shared/asset_generators.sh | 10 +++++++--- scripts/app_config/shared/link_assets.sh | 4 +++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 933d615dbb..a9370c65e1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -276,7 +276,8 @@ 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/" @@ -867,7 +868,8 @@ 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/" @@ -1320,7 +1322,8 @@ 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/" 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 From d3caaa1e1d1f7ae05b96cb09cd83dbc8ca1dce7d Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 10 Nov 2026 16:49:42 -0600 Subject: [PATCH 06/13] Document lock requirements for local dnix package overrides --- docs/building.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/building.md b/docs/building.md index 349321b075..3ecebb9dec 100644 --- a/docs/building.md +++ b/docs/building.md @@ -50,6 +50,12 @@ 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 | From 9455d0d02b9113a502f0ca8f2aebdc5c098a7b8c Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Thu, 12 Nov 2026 07:05:46 -0600 Subject: [PATCH 07/13] Install xcodes from Homebrew core The xcodesorg/made tap no longer installs on macOS 27: Homebrew 7 cannot match its mojave bottle tags, and its source build fails with the Swift 6.4 toolchain. The homebrew-core formula ships a macOS 27 bottle. Validation: xcode_setup_test.dart passes. brew install xcodes was not run on a real machine. --- docs/building.md | 5 +++-- tool/setup_xcode.dart | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/building.md b/docs/building.md index 3ecebb9dec..9bc4192f3a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -138,11 +138,12 @@ below when keeping multiple builds of 27.0 installed. ### Install with xcodes -Install the [xcodes CLI](https://github.com/XcodesOrg/xcodes), then run our +Install the [xcodes CLI](https://github.com/XcodesOrg/xcodes) from +[Homebrew core](https://formulae.brew.sh/formula/xcodes), then run our standalone helper from the repository root: ```sh -brew install xcodesorg/made/xcodes +brew install xcodes dart tool/setup_xcode.dart ``` diff --git a/tool/setup_xcode.dart b/tool/setup_xcode.dart index 11293f5f75..510d581469 100644 --- a/tool/setup_xcode.dart +++ b/tool/setup_xcode.dart @@ -126,7 +126,7 @@ if ! find_xcode; then 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 + echo 'Homebrew option: brew install xcodes' >&2 exit 1 fi if ! help=$("$XCODES" install --help); then From b1f71500676637cca9fa08f1ea90ca53512f3d77 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Thu, 12 Nov 2026 07:10:21 -0600 Subject: [PATCH 08/13] Link the Nix installer from the Mac build section --- docs/building.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/building.md b/docs/building.md index 9bc4192f3a..ba5b15f424 100644 --- a/docs/building.md +++ b/docs/building.md @@ -128,7 +128,9 @@ not supported. Enable KVM for hardware-accelerated emulation on Linux. ## Mac host -Install Flutter 3.47.2 and Nix on an Apple Silicon Mac. **Xcode 27.0 is required +On an Apple Silicon Mac, install Flutter 3.47.2 and +[Nix](https://nixos.org/download/) for source mode. Check `nix --version` from +the same environment that runs Flutter. **Xcode 27.0 is required for verified source builds of the Apple libraries** (release build **27A266a**); a different version can produce different hashes. Prebuilt mode still needs Xcode to build the Flutter app. Install 27.0 alongside your existing Xcode, under `/Applications` with a From a3ba099e50c0d816d9d7a1373aaf30d83c7048cf Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Thu, 12 Nov 2026 06:43:49 -0600 Subject: [PATCH 09/13] Fix native build setup and remove secp caching Select Isar's Rust toolchain explicitly, filter Xcode metadata by app, and preserve Windows Dart PATH precedence. Remove secp receipt caching and update focused tests and docs. Validation: Dart analysis, formatting, shell syntax, and git diff --check passed. Regression tests and native builds were not executed. --- docs/building.md | 23 ++-- scripts/app_config/templates/isar_build.sh | 4 +- scripts/build_app.sh | 4 +- scripts/tests/build_input_test.dart | 48 +++++++++ .../native_dependency_contract_test.dart | 22 ++++ .../tests/windows_native_overrides_test.dart | 3 +- scripts/tests/windows_secp_build_test.dart | 102 ++++-------------- scripts/windows/build_secp256k1.dart | 78 +------------- tool/check_build_environment.dart | 1 + tool/check_native_overrides.dart | 9 +- tool/native_dependency_contract.dart | 3 + 11 files changed, 124 insertions(+), 173 deletions(-) diff --git a/docs/building.md b/docs/building.md index ba5b15f424..030e5c39c9 100644 --- a/docs/building.md +++ b/docs/building.md @@ -74,10 +74,11 @@ check for missing locks. 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. +Stack Wallet also needs it for Xelis. These hooks install their pinned toolchains +automatically. The optional Isar source build (`-f`) separately uses Rust +**1.99.1**, installing it through Rustup if missing without changing your default +toolchain. Nix supplies the four dnix packages' own pinned toolchains, +independently of the Rust installed on the host. The macOS app deployment target is now **12.0** to accommodate the native libraries. iOS remains 15.0, and the current Flutter Android minimum is API 24. @@ -169,8 +170,10 @@ The helper and preflight read `toolchains.xcode` and, when available, 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`. +Preflight checks only packages enabled for the selected app; the standalone +setup helper checks all four packages. On a fresh checkout, metadata is fetched +from the pinned GitHub commits when it is absent from the pub cache; this also +applies to `--print`. The installer runs in a clean environment without shell startup files or build commands. `xcodes` handles Apple account prompts; `sudo -k` prompts directly for @@ -296,9 +299,9 @@ dart scripts/windows/build_secp256k1.dart 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. +hash-verification guarantee applies to its four packages, not secp256k1. Each +build recompiles the DLL; `-i` skips this step during configuration. The old MXE +setup and Epic-specific CMake upgrade are not needed for dnix. 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 @@ -419,7 +422,7 @@ dart scripts/tests/build_input_test.dart 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 +secp256k1 build helper. A small Windows CI job runs the PowerShell prebuild regression tests. Actual native Windows to WSL builds are tested in dnix's CI; these app tests do not run WSL. diff --git a/scripts/app_config/templates/isar_build.sh b/scripts/app_config/templates/isar_build.sh index d1d53d7f93..4b78990365 100644 --- a/scripts/app_config/templates/isar_build.sh +++ b/scripts/app_config/templates/isar_build.sh @@ -45,7 +45,9 @@ build_isar_core() { return 0 fi - (cd "${isar_core_path}" && cargo build --release) || { + # Use the toolchain formerly supplied by the Docker image, without changing + # the developer's default. Rustup installs it only when it is missing. + (cd "${isar_core_path}" && rustup run --install 1.99.1 cargo build --release) || { echo "Error: cargo build failed for isar_core_ffi" >&2 return 1 } diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 54fa4335e5..03e63b5673 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -156,8 +156,8 @@ if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then popd ;; windows) - # -d only selects dnix prebuilts. This separate library is skipped - # with -i, or reused when its local build receipt still matches. + # -d only selects dnix prebuilts. Rebuild this separate library + # unless -i skips platform native dependencies. pushd "${APP_PROJECT_ROOT_DIR}" dart "${APP_PROJECT_ROOT_DIR}/scripts/windows/build_secp256k1.dart" popd diff --git a/scripts/tests/build_input_test.dart b/scripts/tests/build_input_test.dart index 5cbbb88d5a..2db1b7588b 100644 --- a/scripts/tests/build_input_test.dart +++ b/scripts/tests/build_input_test.dart @@ -120,6 +120,54 @@ exit 91 } }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); + for (final toolchainStatus in [0, 42]) { + test('Isar toolchain invocation handles exit $toolchainStatus', () async { + final workspace = Directory(p.join(root.path, 'isar source')) + ..createSync(); + final core = Directory(p.join(workspace.path, 'isar_core_ffi')) + ..createSync(); + File(p.join(core.path, 'Cargo.toml')).writeAsStringSync('fixture'); + final calls = File(p.join(root.path, 'rustup-call')); + final result = await Process.run( + 'bash', + [ + '-c', + r''' +source "$1" +rustup() { + printf '%s\n' "$PWD" "$@" > "$ISAR_TEST_CALLS" + return "$ISAR_TEST_STATUS" +} +cargo() { return 99; } +build_isar_core "$2" "$3" +''', + 'isar-toolchain-test', + p.join(repository.path, 'scripts/app_config/templates/isar_build.sh'), + core.path, + workspace.path, + ], + environment: { + 'ISAR_TEST_CALLS': calls.path, + 'ISAR_TEST_STATUS': '$toolchainStatus', + }, + ); + expect( + result.exitCode, + toolchainStatus == 0 ? 0 : 1, + reason: '${result.stderr}', + ); + expect(calls.readAsLinesSync(), [ + core.path, + 'run', + '--install', + '1.99.1', + 'cargo', + 'build', + '--release', + ]); + }, skip: Platform.isWindows ? 'Requires POSIX bash' : false); + } + group('prebuild', () { const coins = [ 'bitcoin', diff --git a/scripts/tests/native_dependency_contract_test.dart b/scripts/tests/native_dependency_contract_test.dart index 848fd7c0b6..2120b128d8 100644 --- a/scripts/tests/native_dependency_contract_test.dart +++ b/scripts/tests/native_dependency_contract_test.dart @@ -68,6 +68,28 @@ void main() { expect(actual.upstreamBuildPinned, isTrue); }); + test('Xcode metadata is loaded only for the selected app', () async { + for (final entry in const { + 'stack_wallet': dnixPackageNames, + 'stack_duo': {'frostdart', 'tor_ffi_plugin'}, + 'campfire': {'tor_ffi_plugin'}, + }.entries) { + final loaded = []; + await loadXcodeRequirement( + project, + app: entry.key, + readMetadata: (dependency) async { + expect(entry.value, contains(dependency.name)); + loaded.add(dependency.name); + return { + 'toolchains': {'xcode': '28.1', 'xcodeBuild': '28B123'}, + }; + }, + ); + expect(loaded, unorderedEquals(entry.value), reason: entry.key); + } + }); + test('new version-only revision cannot inherit the previous build', () async { final template = File.fromUri( project.uri.resolve('scripts/app_config/templates/pubspec.template.yaml'), diff --git a/scripts/tests/windows_native_overrides_test.dart b/scripts/tests/windows_native_overrides_test.dart index b5f598f095..ad3edd4237 100644 --- a/scripts/tests/windows_native_overrides_test.dart +++ b/scripts/tests/windows_native_overrides_test.dart @@ -45,7 +45,8 @@ void main() { "& \$dart 'C:\\Users\\O''Brien & %HOME%\\工具\\check.dart' 'stack_wallet' windows;", ), ); - expect(command, contains('Get-Command dart.exe -CommandType Application')); + expect(command, contains('Get-Command dart -CommandType Application')); + expect(command, isNot(contains('Get-Command dart.exe'))); expect(command, contains(r"'cache\dart-sdk\bin\dart.exe'")); expect(command, endsWith(r'exit $LASTEXITCODE')); expect(result.exitCode, 23); diff --git a/scripts/tests/windows_secp_build_test.dart b/scripts/tests/windows_secp_build_test.dart index df37427819..b99e584ab2 100644 --- a/scripts/tests/windows_secp_build_test.dart +++ b/scripts/tests/windows_secp_build_test.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'dart:isolate'; -import 'package:crypto/crypto.dart'; import 'package:test/test.dart'; import '../windows/build_secp256k1.dart'; @@ -14,7 +13,6 @@ void main() { 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; @@ -75,12 +73,10 @@ void main() { 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': @@ -98,7 +94,7 @@ void main() { WindowsSecpBuilder builder({bool nativeWindows = true}) => WindowsSecpBuilder( root: root, nativeWindows: nativeWindows, - environment: {'ProgramFiles(x86)': programFiles, ...compilerFlags}, + environment: {'ProgramFiles(x86)': programFiles}, cleanup: (directory) { if (cleanupLocked) { throw FileSystemException('fixture locked directory', directory.path); @@ -116,10 +112,6 @@ void main() { ? 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'); @@ -237,10 +229,7 @@ void main() { File('${root.path}/build/secp256k1.dll').readAsStringSync(), 'fixture Windows library', ); - expect( - File('${root.path}/build/secp256k1.dll.json').existsSync(), - isTrue, - ); + expect(Directory('${root.path}/build').listSync(), hasLength(1)); }, ); @@ -257,98 +246,51 @@ void main() { expect(calls.any((c) => c.executable.contains('mingw32-g++')), isFalse); }); - test('matching receipt avoids fetching and recompiling', () async { + test('an existing DLL is rebuilt on every invocation', () 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(operation('--build'), 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(); + test('the newest complete SDK is selected for CMake', () async { 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('$operation failure propagates without publishing a DLL', () async { + failAt = operation; + await expectLater(builder().build(), throwsA(isA())); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isFalse); + }); } - test('a 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( + 'a missing built DLL is rejected and preserves the previous DLL', + () async { + final output = File('${root.path}/build/secp256k1.dll'); + output.parent.createSync(recursive: true); + output.writeAsStringSync('previous Windows library'); + missingDll = true; + await expectLater(builder().build(), throwsA(isA())); + expect(output.readAsStringSync(), 'previous Windows library'); + }, + ); test('locked cleanup leaves a successfully published DLL usable', () async { cleanupLocked = true; await builder().build(); expect(File('${root.path}/build/secp256k1.dll').existsSync(), isTrue); - 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 { @@ -364,7 +306,7 @@ void main() { ), ), ); - expect(File('${root.path}/build/secp256k1.dll.json').existsSync(), isFalse); + expect(File('${root.path}/build/secp256k1.dll').existsSync(), isFalse); }); group('WSL entry point', () { diff --git a/scripts/windows/build_secp256k1.dart b/scripts/windows/build_secp256k1.dart index 5f2f0ac859..905c8c6a01 100644 --- a/scripts/windows/build_secp256k1.dart +++ b/scripts/windows/build_secp256k1.dart @@ -1,5 +1,4 @@ // 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 @@ -42,7 +41,6 @@ class WindowsSecpBuilder { 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(); @@ -139,25 +137,9 @@ class WindowsSecpBuilder { 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) { @@ -193,12 +175,9 @@ class WindowsSecpBuilder { '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('x86_64-w64-mingw32-gcc', ['--version']); await checked('make', ['--version']); } final bundledCmake = @@ -217,58 +196,14 @@ class WindowsSecpBuilder { '(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. + // to discover the tools and SDK actually used by this build. 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'; @@ -328,14 +263,9 @@ class WindowsSecpBuilder { '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. + // The temporary DLL is on the destination filesystem. Publish it only + // after a successful build, preserving any previous DLL on build failure. library.renameSync(output.path); - newReceipt.renameSync(receipt.path); stdout.writeln( 'Built ${output.path} from secp256k1 v0.5.0 ($secpRevision).', ); diff --git a/tool/check_build_environment.dart b/tool/check_build_environment.dart index 873bad64dc..1145f0b84c 100644 --- a/tool/check_build_environment.dart +++ b/tool/check_build_environment.dart @@ -309,6 +309,7 @@ Future main(List args) async { try { requirement = await loadXcodeRequirement( File.fromUri(Platform.script).parent.parent, + app: args[1], ); } catch (error) { stderr.writeln('Cannot determine the required Xcode: $error'); diff --git a/tool/check_native_overrides.dart b/tool/check_native_overrides.dart index 96597a64e0..64438d0f38 100644 --- a/tool/check_native_overrides.dart +++ b/tool/check_native_overrides.dart @@ -14,15 +14,14 @@ Future _run(String executable, List 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. + // Preserve PATH order, then unwrap Flutter's dart.bat to avoid cmd.exe + // expanding 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 = $launcher.Source; ' + r"if ([System.IO.Path]::GetExtension($dart) -ine '.exe') { " r'$dart = Join-Path ' r'([System.IO.Path]::GetDirectoryName($launcher.Source)) ' r"'cache\dart-sdk\bin\dart.exe'; " diff --git a/tool/native_dependency_contract.dart b/tool/native_dependency_contract.dart index 2739b0cd2d..85e4bfce19 100644 --- a/tool/native_dependency_contract.dart +++ b/tool/native_dependency_contract.dart @@ -124,8 +124,10 @@ typedef NativeMetadataLoader = Future> Function( Future loadXcodeRequirement( Directory project, { + String app = 'stack_wallet', NativeMetadataLoader? readMetadata, }) async { + final enabled = enabledDnixPackages(app); final compatibility = jsonDecode( File.fromUri( project.uri.resolve('tool/native_dependency_compatibility.json'), @@ -139,6 +141,7 @@ Future loadXcodeRequirement( XcodeRequirement? required; var allPinned = true; for (final dependency in readNativeDependencies(project)) { + if (!enabled.contains(dependency.name)) continue; final metadata = await (readMetadata?.call(dependency) ?? _readMetadata(dependency, cache)); From 9fa25bd354db0fc8568838028e9cd9f6d7d84397 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Thu, 12 Nov 2026 16:27:20 -0600 Subject: [PATCH 10/13] Configure Windows builds only from Git Bash Native Windows Flutter always builds the app, and Git Bash is already the documented route and the one CI uses. Remove the legacy WSL-shell configuration route: build_app.sh and the preflight reject Windows configuration from other hosts, the override guard no longer dispatches to PowerShell and takes only the app, and the preflight no longer probes Windows Rustup through cmd.exe. Build secp256k1 with MSVC only, removing its WSL wrapper and the orphaned MinGW deps script. Call cygpath, flutter and dart directly in the asset scripts. Build mwebd.exe from source only on Windows (fetching still works anywhere) and drop MinGW from the CI image. dnix and the MWEB helper still use WSL2 as their Windows build backend. Validation: build_input, native_build_config, configure_native_assets, build_environment, windows_secp_build and xcode_setup tests pass on macOS; native_dependency_contract fails only its app-package metadata test, as on the parent commit. Dart analysis with the repo lints, formatting, Bash syntax, and git diff --check passed. Nothing was run on Windows, and the Windows release workflow has not run. --- .github/workflows/test.yaml | 1 - Dockerfile | 3 +- docs/building.md | 15 +-- scripts/app_config/shared/asset_generators.sh | 24 +--- scripts/app_config/shared/link_assets.sh | 9 +- scripts/build_app.sh | 11 +- scripts/tests/build_environment_test.dart | 117 ++---------------- scripts/tests/native_build_config_test.dart | 26 +++- .../tests/windows_native_overrides_test.dart | 103 --------------- scripts/tests/windows_secp_build_test.dart | 103 ++------------- scripts/windows/build_secp256k1.dart | 110 +++++++--------- scripts/windows/build_secp256k1_wsl.sh | 16 --- scripts/windows/deps.sh | 8 -- tool/build_standalone_mwebd_windows.dart | 52 +++----- tool/check_build_environment.dart | 61 +++------ tool/check_native_overrides.dart | 91 +------------- 16 files changed, 143 insertions(+), 607 deletions(-) delete mode 100644 scripts/tests/windows_native_overrides_test.dart delete mode 100755 scripts/windows/build_secp256k1_wsl.sh delete mode 100644 scripts/windows/deps.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9cd28caeb1..4528d836a2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -113,7 +113,6 @@ jobs: 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 diff --git a/Dockerfile b/Dockerfile index 8d6246f7f5..b2b19812c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsecret-1-dev libssl-dev libtss2-dev \ ocl-icd-opencl-dev opencl-headers valac zlib1g-dev \ g++-aarch64-linux-gnu gcc-aarch64-linux-gnu \ - g++-mingw-w64-x86-64 gcc-mingw-w64-x86-64 \ openjdk-21-jdk-headless \ && rm -rf /var/lib/apt/lists/* @@ -93,7 +92,7 @@ RUN git config --system --add safe.directory '*' RUN flutter --version && rustup --version && node --version && go version -# Android-only image: no Linux/Windows cross-compilers or OpenCV/OpenCL; +# Android-only image: no Linux cross-compilers or OpenCV/OpenCL; # Rust toolchains and targets are installed by hooks at runtime. FROM ubuntu:24.04 AS android diff --git a/docs/building.md b/docs/building.md index 030e5c39c9..6a3362c360 100644 --- a/docs/building.md +++ b/docs/building.md @@ -296,23 +296,12 @@ app configuration and stops on failed build commands. After `-i`, run: dart scripts/windows/build_secp256k1.dart ``` -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 +The helper builds secp256k1 **v0.5.0** +(`e3a885d42a7800c1ccebad94ad1e2b82c4df5c65`) with MSVC. dnix's hash-verification guarantee applies to its four packages, not secp256k1. Each build recompiles the DLL; `-i` skips this step during configuration. The old MXE setup and Epic-specific CMake upgrade are not needed for dnix. -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. - ## Configure and build Clone the repository, then run the setup from its root: diff --git a/scripts/app_config/shared/asset_generators.sh b/scripts/app_config/shared/asset_generators.sh index a03b977cc8..626ce32622 100755 --- a/scripts/app_config/shared/asset_generators.sh +++ b/scripts/app_config/shared/asset_generators.sh @@ -12,26 +12,10 @@ APP_BUILD_PLATFORM=$1 # run icon and image generators pushd "${APP_PROJECT_ROOT_DIR}" YAML_FILE="${APP_PROJECT_ROOT_DIR}/scripts/app_config/platforms/${APP_BUILD_PLATFORM}/flutter_launcher_icons.yaml" -if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then - # 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 - 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 - flutter pub get - dart run flutter_launcher_icons -f "${YAML_FILE}" +flutter pub get +dart run flutter_launcher_icons -f "${YAML_FILE}" - if [[ "${APP_BUILD_PLATFORM}" = 'ios' || "${APP_BUILD_PLATFORM}" = 'android' ]]; then - dart run flutter_native_splash:create - fi +if [[ "${APP_BUILD_PLATFORM}" = 'ios' || "${APP_BUILD_PLATFORM}" = 'android' ]]; then + dart run flutter_native_splash:create fi popd diff --git a/scripts/app_config/shared/link_assets.sh b/scripts/app_config/shared/link_assets.sh index 4b98d7449c..7dec8ed960 100755 --- a/scripts/app_config/shared/link_assets.sh +++ b/scripts/app_config/shared/link_assets.sh @@ -23,13 +23,8 @@ for dirname in "default_themes" "icon" "lottie" "in_app_logo_icons" "svg"; do rm -f "${ASSETS_DIR}/${dirname}" if [[ "${APP_BUILD_PLATFORM}" = 'windows' ]]; then - if command -v cygpath >/dev/null 2>&1; then - LINK_SOURCE_DIR_WIN_PATH_VERSION=$(cygpath -w "${LINK_SOURCE_DIR}") - LINK_NAME_WIN_PATH_VERSION=$(cygpath -w "${ASSETS_DIR}") - else - LINK_SOURCE_DIR_WIN_PATH_VERSION=$(wslpath -w "${LINK_SOURCE_DIR}") - LINK_NAME_WIN_PATH_VERSION=$(wslpath -w "${ASSETS_DIR}") - fi + LINK_SOURCE_DIR_WIN_PATH_VERSION=$(cygpath -w "${LINK_SOURCE_DIR}") + LINK_NAME_WIN_PATH_VERSION=$(cygpath -w "${ASSETS_DIR}") # Keep CMD switches out of Git Bash's path conversion; paths are native above. MSYS2_ARG_CONV_EXCL="/C;/D${MSYS2_ARG_CONV_EXCL:+;$MSYS2_ARG_CONV_EXCL}" \ cmd.exe /C mklink /D "${LINK_NAME_WIN_PATH_VERSION}\\${dirname}" "${LINK_SOURCE_DIR_WIN_PATH_VERSION}" diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 03e63b5673..b03fbc4b42 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -102,7 +102,14 @@ 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" +# Windows apps are configured from Git Bash and built by native Windows Flutter. +if [ "$APP_BUILD_PLATFORM" = windows ]; then + case "$(uname -s)" in + MINGW*|MSYS*) ;; + *) echo "Configure Windows builds from Git Bash on Windows." >&2; exit 1 ;; + esac +fi +dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" DNIX_DISTRIBUTION_ARGS=() if [ -n "$DNIX_WSL_DISTRIBUTION" ]; then DNIX_DISTRIBUTION_ARGS=("$DNIX_WSL_DISTRIBUTION") @@ -142,7 +149,7 @@ if [[ "$APP_BUILD_PLATFORM" != "linux" ]]; then 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" +dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" # Build platform native deps that are not provided by pub packages. The crypto # plugins (flutter_libepiccash, flutter_libmwc, frostdart, tor_ffi_plugin) are native-assets diff --git a/scripts/tests/build_environment_test.dart b/scripts/tests/build_environment_test.dart index 9ffad3ece7..51900b3cf8 100644 --- a/scripts/tests/build_environment_test.dart +++ b/scripts/tests/build_environment_test.dart @@ -17,11 +17,7 @@ class HostFixture { 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']; @@ -41,7 +37,6 @@ class HostFixture { '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 { @@ -65,18 +60,6 @@ class HostFixture { 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, ''); } @@ -219,95 +202,17 @@ void main() { ); }); - 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'); - }); + for (final os in ['linux', 'macos']) { + test('Windows configuration on a $os host is rejected', () async { + fixture.os = os; + for (final mode in ['source', 'prebuilt']) { + expect(await fixture.check(platform: 'windows', mode: mode), [ + 'Configure Windows builds from Git Bash on Windows.', + ]); + } + expect(fixture.calls, isEmpty); + }); + } test('global GA Xcode wins over an alphabetically earlier beta', () async { fixture.applications.insert(0, '/Applications/Xcode-27.0-Beta.app'); diff --git a/scripts/tests/native_build_config_test.dart b/scripts/tests/native_build_config_test.dart index e30fa5774d..cb8c1b1db1 100644 --- a/scripts/tests/native_build_config_test.dart +++ b/scripts/tests/native_build_config_test.dart @@ -98,6 +98,14 @@ printf '0000000000000000000000000000000000000000\n' final dart = Platform.resolvedExecutable.replaceAll("'", "'\\''"); script('bin/dart', "exec '$dart' \"\$@\""); script('bin/sha256sum', "printf 'fixture-hash mwebd.exe\\n'"); + // build_app.sh configures Windows only from Git Bash. + script('bin/uname', r''' +if [ "${1:-}" = -s ] && [ -n "${DNIX_TEST_UNAME:-}" ]; then + printf '%s\n' "$DNIX_TEST_UNAME" +else + exec /usr/bin/uname "$@" +fi +'''); // The real preflight/helper have separate tests with injected native tools. // These fixtures exercise their position in the configuration flow and cwd. File(p.join(root.path, 'tool/check_native_overrides.dart')) @@ -105,7 +113,7 @@ printf '0000000000000000000000000000000000000000\n' import 'dart:convert'; import 'dart:io'; void main(List args) { - if (args.length != 2) exit(64); + if (args.length != 1) exit(64); final root = File.fromUri(Platform.script).parent.parent; final calls = File('\${root.path}/override-checks'); final count = calls.existsSync() ? calls.readAsLinesSync().length : 0; @@ -188,6 +196,7 @@ void main(List args) { environment: { 'PATH': '${bin.path}:${Platform.environment['PATH']}', 'DNIX_WSL_DISTRIBUTION': '', + if (platform == 'windows') 'DNIX_TEST_UNAME': 'MINGW64_NT-10.0', ...environment, }, ); @@ -201,7 +210,7 @@ void main(List args) { final calls = overrideChecks.readAsLinesSync(); expect(calls, hasLength(previousChecks + 2)); for (final call in calls.skip(previousChecks)) { - expect(jsonDecode(call), [app, platform]); + expect(jsonDecode(call), [app]); } final pubspec = loadYaml( File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(), @@ -386,6 +395,19 @@ void main(List args) { ); }); + test('Windows configuration outside Git Bash changes nothing', () async { + final spec = File(p.join(root.path, 'pubspec.yaml')) + ..writeAsStringSync('unchanged'); + await configure( + 'stack_wallet', + platform: 'windows', + environment: {'DNIX_TEST_UNAME': 'Linux'}, + expectedExit: 1, + ); + expect(spec.readAsStringSync(), 'unchanged'); + expect(File(p.join(root.path, 'override-checks')).existsSync(), isFalse); + }); + test('renderer rejects invalid modes and WSL names', () { expect( () => configureNativeAssets('', 'typo', null), diff --git a/scripts/tests/windows_native_overrides_test.dart b/scripts/tests/windows_native_overrides_test.dart deleted file mode 100644 index ad3edd4237..0000000000 --- a/scripts/tests/windows_native_overrides_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -// 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 -CommandType Application')); - expect(command, isNot(contains('Get-Command dart.exe'))); - 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 index b99e584ab2..b98c57b79d 100644 --- a/scripts/tests/windows_secp_build_test.dart +++ b/scripts/tests/windows_secp_build_test.dart @@ -17,7 +17,7 @@ void main() { bool missingDll = false; bool cleanupLocked = false; - test('secp pin matches resolved coinlib Windows recipes', () async { + test('secp pin matches the resolved coinlib Windows recipe', () async { // Resolve the dependency selected by pub, not a copied pin or cache path. // Updating coinlib must review this contract too. final library = await Isolate.resolvePackageUri( @@ -26,25 +26,14 @@ void main() { 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) { @@ -91,9 +80,8 @@ void main() { }); tearDown(() => root.delete(recursive: true)); - WindowsSecpBuilder builder({bool nativeWindows = true}) => WindowsSecpBuilder( + WindowsSecpBuilder builder() => WindowsSecpBuilder( root: root, - nativeWindows: nativeWindows, environment: {'ProgramFiles(x86)': programFiles}, cleanup: (directory) { if (cleanupLocked) { @@ -113,7 +101,7 @@ void main() { : result(' KitsRoot10 REG_SZ $sdkRoot\n'); } if (args.length == 1 && args.single == '--version') { - if (executable == 'cmake' && nativeWindows) { + if (executable == 'cmake') { return result('cmake version 3.12.0'); } return result( @@ -135,7 +123,7 @@ void main() { if (operation == '--build') { if (!missingDll) { final file = File( - '${args[1]}/src/${nativeWindows ? 'RelWithDebInfo/' : ''}libsecp256k1-2.dll', + '${args[1]}/src/RelWithDebInfo/libsecp256k1-2.dll', ); file.parent.createSync(recursive: true); file.writeAsStringSync('fixture Windows library'); @@ -179,19 +167,11 @@ void main() { 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('rejects unsupported CMake', () async { + cmakeVersion = 'cmake version 3.20.6'; + await expectLater(builder().check(), throwsA(isA())); + expect(Directory('${root.path}/build').existsSync(), isFalse); + }); test( 'native build pins v0.5.0 and writes DLL relative to repository', @@ -233,19 +213,6 @@ void main() { }, ); - 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('an existing DLL is rebuilt on every invocation', () async { await builder().build(); calls.clear(); @@ -308,56 +275,4 @@ void main() { ); expect(File('${root.path}/build/secp256k1.dll').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/windows/build_secp256k1.dart b/scripts/windows/build_secp256k1.dart index 905c8c6a01..6676bbf217 100644 --- a/scripts/windows/build_secp256k1.dart +++ b/scripts/windows/build_secp256k1.dart @@ -27,20 +27,18 @@ class SecpBuildException implements Exception { 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 String visualStudio; + late ({String root, String version}) windowsSdk; static int compareVersions(String a, String b) { final left = a.split('.').map(int.parse).toList(); @@ -140,56 +138,49 @@ class WindowsSecpBuilder { // No checkout, output creation, or compilation: safe before app config. Future check() async { await checked('git', ['--version']); - 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', + 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).', ); - if (visualStudio!.isEmpty || !toolsVersion.existsSync()) { - throw SecpBuildException( - 'Install the Visual Studio 2022 C++ build tools and Windows SDK.', - ); - } - windowsSdk = await findWindowsSdk(); - } else { - await checked('x86_64-w64-mingw32-gcc', ['--version']); - await checked('make', ['--version']); } + 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.', + ); + } + windowsSdk = await findWindowsSdk(); final bundledCmake = '$visualStudio/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe'; - cmake = nativeWindows && File(bundledCmake).existsSync() - ? bundledCmake - : 'cmake'; + cmake = File(bundledCmake).existsSync() ? bundledCmake : 'cmake'; final cmakeVersion = await checked(cmake, ['--version']); final version = RegExp(r'^cmake version (\d+\.\d+(?:\.\d+)?)') .firstMatch(cmakeVersion.stdout.toString()) ?.group(1); - final minimum = nativeWindows ? '3.21' : '3.13'; + const minimum = '3.21'; if (version == null || compareVersions(version, minimum) < 0) { throw SecpBuildException( 'CMake $minimum or newer is required for Windows secp256k1 ' @@ -227,18 +218,12 @@ class WindowsSecpBuilder { 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', - ], + '-G', + 'Visual Studio 17 2022', + '-A', + 'x64', + '-DCMAKE_GENERATOR_INSTANCE=$visualStudio', + '-DCMAKE_SYSTEM_VERSION=${windowsSdk.version}', '-DCMAKE_BUILD_TYPE=RelWithDebInfo', '-DBUILD_SHARED_LIBS=ON', '-DSECP256K1_ENABLE_MODULE_RECOVERY=ON', @@ -255,9 +240,7 @@ class WindowsSecpBuilder { 'RelWithDebInfo', '--parallel', ]); - final library = File( - '$buildDir/src/${nativeWindows ? 'RelWithDebInfo/' : ''}libsecp256k1-2.dll', - ); + final library = File('$buildDir/src/RelWithDebInfo/libsecp256k1-2.dll'); if (!library.existsSync() || library.lengthSync() == 0) { throw SecpBuildException( 'CMake did not produce the expected DLL: ${library.path}', @@ -292,14 +275,13 @@ Future main(List args) async { exitCode = 64; return; } - if (!Platform.isWindows && !Platform.isLinux) { - stderr.writeln('Windows secp256k1 requires native Windows or Linux/WSL.'); + if (!Platform.isWindows) { + stderr.writeln('Windows secp256k1 requires native Windows.'); exitCode = 1; return; } final builder = WindowsSecpBuilder( root: File.fromUri(Platform.script).parent.parent.parent, - nativeWindows: Platform.isWindows, environment: Platform.environment, ); try { diff --git a/scripts/windows/build_secp256k1_wsl.sh b/scripts/windows/build_secp256k1_wsl.sh deleted file mode 100755 index e0790f60f9..0000000000 --- a/scripts/windows/build_secp256k1_wsl.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/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 -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/scripts/windows/deps.sh b/scripts/windows/deps.sh deleted file mode 100644 index bae12bceb6..0000000000 --- a/scripts/windows/deps.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -# Toolchain for cross-compiling secp256k1.dll from WSL (build_secp256k1_wsl.sh). -sudo apt install clang gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 -sudo apt install libgtk2.0-dev - -wait -echo "Done building" diff --git a/tool/build_standalone_mwebd_windows.dart b/tool/build_standalone_mwebd_windows.dart index fe81b13d00..4531b7a636 100644 --- a/tool/build_standalone_mwebd_windows.dart +++ b/tool/build_standalone_mwebd_windows.dart @@ -9,6 +9,12 @@ Future main(List args) async { if (args.contains("--fetch")) { await _fetchPrebuilt(projectToolDir); + } else if (!Platform.isWindows) { + stderr.writeln( + "Building mwebd.exe requires native Windows. Use --fetch for the " + "published release.", + ); + exit(1); } else { await _buildFromSource(projectToolDir); } @@ -101,7 +107,7 @@ Future _buildFromSource(Directory projectToolDir) async { ); final isCI = Platform.environment['CI'] == 'true'; final Process build; - if (Platform.isWindows && isCI) { + if (isCI) { build = await Process.start( "go", [ @@ -115,7 +121,7 @@ Future _buildFromSource(Directory projectToolDir) async { runInShell: true, mode: ProcessStartMode.inheritStdio, ); - } else if (Platform.isWindows) { + } else { build = await Process.start( "wsl", [ @@ -128,25 +134,6 @@ Future _buildFromSource(Directory projectToolDir) async { runInShell: true, mode: ProcessStartMode.inheritStdio, ); - } else { - build = await Process.start( - "go", - [ - "build", - "-v", - "-o", - "../mwebd.exe", - "github.com/ltcmweb/mwebd/cmd/mwebd", - ], - environment: { - "GOOS": "windows", - "GOARCH": "amd64", - "CGO_ENABLED": "1", - "CC": "x86_64-w64-mingw32-gcc", - }, - runInShell: true, - mode: ProcessStartMode.inheritStdio, - ); } await _waitForProcess(build); @@ -161,21 +148,14 @@ Future _buildFromSource(Directory projectToolDir) async { } // copy the build mwebd.exe to assets/windows - final copy = Platform.isWindows - ? await Process.start("cmd", [ - "/C", - "copy", - "${Directory.current.parent.path}" - "${Platform.pathSeparator}mwebd.exe", - "${winAssetsDir.path}" - "${Platform.pathSeparator}mwebd.exe", - ], mode: ProcessStartMode.inheritStdio) - : await Process.start("cp", [ - "${Directory.current.parent.path}" - "${Platform.pathSeparator}mwebd.exe", - "${winAssetsDir.path}" - "${Platform.pathSeparator}mwebd.exe", - ], mode: ProcessStartMode.inheritStdio); + final copy = await Process.start("cmd", [ + "/C", + "copy", + "${Directory.current.parent.path}" + "${Platform.pathSeparator}mwebd.exe", + "${winAssetsDir.path}" + "${Platform.pathSeparator}mwebd.exe", + ], mode: ProcessStartMode.inheritStdio); await _waitForProcess(copy); // cleanup diff --git a/tool/check_build_environment.dart b/tool/check_build_environment.dart index 1145f0b84c..2b967a45b1 100644 --- a/tool/check_build_environment.dart +++ b/tool/check_build_environment.dart @@ -102,65 +102,36 @@ Future> checkBuildEnvironment({ 'Apple source builds need the native Xcode requirement.', ); } + if (platform == 'windows' && host.os != 'windows') { + return ['Configure Windows builds from Git Bash on Windows.']; + } 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; - } + 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/). ' + 'Install Rustup on the Flutter host (https://rustup.rs/). ' 'Every app needs Rust 1.90.0 for DNSSEC, including with -d.$xelis ' - 'The hooks install their pinned toolchains automatically.' - '$interoperability', + 'The hooks install their pinned toolchains automatically.', ); } 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) { + // Windows hooks run Nix in the default or requested WSL distribution. + if (host.os == 'windows') { final result = await host.command( 'wsl.exe', [ diff --git a/tool/check_native_overrides.dart b/tool/check_native_overrides.dart index 64438d0f38..9c5a009915 100644 --- a/tool/check_native_overrides.dart +++ b/tool/check_native_overrides.dart @@ -1,100 +1,15 @@ // 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("'", "''")}'"; - // Preserve PATH order, then unwrap Flutter's dart.bat to avoid cmd.exe - // expanding percent signs in script paths or arguments. - return r"$ErrorActionPreference = 'Stop'; " - r'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' - r'$launcher = Get-Command dart -CommandType Application ' - r'-ErrorAction Stop; ' - r'$dart = $launcher.Source; ' - r"if ([System.IO.Path]::GetExtension($dart) -ine '.exe') { " - 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]', - ); +void main(List args) { + if (args.length != 1) { + stderr.writeln('Usage: dart tool/check_native_overrides.dart '); 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) { From 8bf200aeb480b3718dbff7ff9d55f0a36fb31fce Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Fri, 13 Nov 2026 09:34:49 -0600 Subject: [PATCH 11/13] Check build hosts earlier and reject an empty WSL override Reject iOS and macOS targets on non-macOS hosts before the prebuilt early return, so -d can no longer start configuring Apple builds on Linux or Windows. Reject an empty -w instead of letting it silently replace DNIX_WSL_DISTRIBUTION with the WSL default; the Dart validator already refuses empty names. List Go and mingw-w64 in the default WSL distribution among the Windows prerequisites for Stack Wallet's MWEB build. Compare the secp test's checkout directory with platform-aware paths so the test passes on native Windows. Validation: build_input, native_build_config, configure_native_assets, build_environment, windows_secp_build and xcode_setup tests pass on macOS; native_dependency_contract fails only its app-package metadata test, which needs the app's flutter pub get. The new tests fail when the Apple host rule or the -w check is removed. Dart analysis with the repo lints, formatting, Bash syntax, and git diff --check passed. Nothing was run on Windows. --- docs/building.md | 2 ++ scripts/build_app.sh | 3 ++- scripts/tests/build_environment_test.dart | 14 ++++++++++++++ scripts/tests/build_input_test.dart | 1 + scripts/tests/windows_secp_build_test.dart | 7 +++++-- tool/check_build_environment.dart | 7 ++++--- 6 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/building.md b/docs/building.md index 6a3362c360..31494af06d 100644 --- a/docs/building.md +++ b/docs/building.md @@ -252,6 +252,8 @@ Install: - Windows Developer Mode for symlinks used by Flutter and the asset scripts. - Rustup on Windows for every app's DNSSEC hook and Stack Wallet's Xelis hook. - WSL2 and Nix inside its Linux distribution for dnix source mode. +- Go and `mingw-w64` in the default WSL distribution for Stack Wallet's MWEB + build, unless you set `MWEBD_FETCH=1` (see [MWEB and secp256k1](#mweb-and-secp256k1)). Follow Microsoft's [WSL installation guide](https://learn.microsoft.com/en-us/windows/wsl/install) to install Ubuntu 24.04, then follow the [Nix installation instructions](https://nixos.org/download/) diff --git a/scripts/build_app.sh b/scripts/build_app.sh index b03fbc4b42..c836fbb912 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -53,7 +53,8 @@ while getopts "v:b:p:a:w:idfsh" opt; do d) DNIX_BUILD_MODE=prebuilt ;; f) BUILD_ISAR_FROM_SOURCE=1 ;; s) USE_SYSTEM_SECURE_STORAGE_DEPS=1 ;; - w) DNIX_WSL_DISTRIBUTION="$OPTARG" ;; + w) [ -n "$OPTARG" ] || { echo "Invalid WSL distribution: -w needs a name"; usage; } + DNIX_WSL_DISTRIBUTION="$OPTARG" ;; h) usage 0 ;; *) usage ;; esac diff --git a/scripts/tests/build_environment_test.dart b/scripts/tests/build_environment_test.dart index 51900b3cf8..090da6570b 100644 --- a/scripts/tests/build_environment_test.dart +++ b/scripts/tests/build_environment_test.dart @@ -202,6 +202,20 @@ void main() { ); }); + for (final os in ['linux', 'windows']) { + test('Apple targets on a $os host are rejected in both modes', () async { + fixture.os = os; + for (final platform in ['ios', 'macos']) { + for (final mode in ['source', 'prebuilt']) { + expect(await fixture.check(platform: platform, mode: mode), [ + 'Apple targets require a macOS build host.', + ]); + } + } + expect(fixture.calls, isEmpty); + }); + } + for (final os in ['linux', 'macos']) { test('Windows configuration on a $os host is rejected', () async { fixture.os = os; diff --git a/scripts/tests/build_input_test.dart b/scripts/tests/build_input_test.dart index 2db1b7588b..e11131529d 100644 --- a/scripts/tests/build_input_test.dart +++ b/scripts/tests/build_input_test.dart @@ -85,6 +85,7 @@ exit 91 (option: 'a', value: 'unknown', message: 'Invalid app id'), (option: 'v', value: '1.2.3/invalid', message: 'Invalid version'), (option: 'b', value: '12-invalid', message: 'Invalid build number'), + (option: 'w', value: '', message: 'Invalid WSL distribution'), ]) { test('rejects invalid -${invalid.option} before writes ' 'from $location', () async { diff --git a/scripts/tests/windows_secp_build_test.dart b/scripts/tests/windows_secp_build_test.dart index b98c57b79d..89ae1ceb65 100644 --- a/scripts/tests/windows_secp_build_test.dart +++ b/scripts/tests/windows_secp_build_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:isolate'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../windows/build_secp256k1.dart'; @@ -190,9 +191,11 @@ void main() { 'https://github.com/bitcoin-core/secp256k1.git', secpRevision, ]); + final checkout = operation('checkout').single.cwd!; expect( - operation('checkout').single.cwd, - startsWith('${root.path}/build/'), + p.isWithin('${root.path}/build', checkout), + isTrue, + reason: checkout, ); final configure = operation('-S').single.args; expect( diff --git a/tool/check_build_environment.dart b/tool/check_build_environment.dart index 2b967a45b1..45bf9e3937 100644 --- a/tool/check_build_environment.dart +++ b/tool/check_build_environment.dart @@ -105,6 +105,9 @@ Future> checkBuildEnvironment({ if (platform == 'windows' && host.os != 'windows') { return ['Configure Windows builds from Git Bash on Windows.']; } + if ({'ios', 'macos'}.contains(platform) && host.os != 'macos') { + return ['Apple targets require a macOS build host.']; + } final errors = []; var hasRustup = false; final home = host.variables[host.os == 'windows' ? 'USERPROFILE' : 'HOME']; @@ -192,9 +195,7 @@ Future> checkBuildEnvironment({ } if (platform == 'ios' || platform == 'macos') { - if (host.os != 'macos') { - errors.add('Apple targets require a macOS build host.'); - } else if (host.architecture == 'arm64') { + if (host.architecture == 'arm64') { final requirement = xcodeRequirement!; final selected = await selectedDnixXcode( host, From 5c1ef943cab364862d687e26d8f28ec67a0f5f7b Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Fri, 13 Nov 2026 10:56:08 -0600 Subject: [PATCH 12/13] Remove unused Windows secp256k1 leftovers Delete scripts/windows/build_secp256k1.bat, which nothing calls now that Windows configuration runs from Git Bash. Drop the pushd/popd around the secp256k1 helper in build_app.sh: the helper locates the project from its own path and gives its commands explicit working directories, so the configuration test now checks only where the DLL lands. Keep only the Windows SDK version the helper passes to CMake, dropping the unused SDK root. Validation: build_input, native_build_config, configure_native_assets, build_environment, windows_secp_build and xcode_setup tests pass on macOS; native_dependency_contract fails only its app-package metadata test, which needs the app's flutter pub get. Dart analysis with the repo lints reports only an existing info lint; formatting, Bash syntax, and git diff --check passed. Nothing was run on Windows. --- scripts/build_app.sh | 2 -- scripts/tests/native_build_config_test.dart | 4 ---- scripts/windows/build_secp256k1.bat | 4 ---- scripts/windows/build_secp256k1.dart | 14 +++++++------- 4 files changed, 7 insertions(+), 17 deletions(-) delete mode 100644 scripts/windows/build_secp256k1.bat diff --git a/scripts/build_app.sh b/scripts/build_app.sh index c836fbb912..231f175095 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -166,9 +166,7 @@ if [ "$SKIP_NATIVE_DEPS_BUILD" -eq 0 ]; then windows) # -d only selects dnix prebuilts. Rebuild this separate library # unless -i skips platform native dependencies. - pushd "${APP_PROJECT_ROOT_DIR}" dart "${APP_PROJECT_ROOT_DIR}/scripts/windows/build_secp256k1.dart" - popd ;; esac fi diff --git a/scripts/tests/native_build_config_test.dart b/scripts/tests/native_build_config_test.dart index cb8c1b1db1..ae9d4910cd 100644 --- a/scripts/tests/native_build_config_test.dart +++ b/scripts/tests/native_build_config_test.dart @@ -270,10 +270,6 @@ void main(List args) { 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, diff --git a/scripts/windows/build_secp256k1.bat b/scripts/windows/build_secp256k1.bat deleted file mode 100644 index 502c7d0e26..0000000000 --- a/scripts/windows/build_secp256k1.bat +++ /dev/null @@ -1,4 +0,0 @@ -@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 index 6676bbf217..96624b1ef7 100644 --- a/scripts/windows/build_secp256k1.dart +++ b/scripts/windows/build_secp256k1.dart @@ -38,7 +38,7 @@ class WindowsSecpBuilder { final void Function(Directory) cleanup; late String cmake; late String visualStudio; - late ({String root, String version}) windowsSdk; + late String windowsSdkVersion; static int compareVersions(String a, String b) { final left = a.split('.').map(int.parse).toList(); @@ -51,7 +51,7 @@ class WindowsSecpBuilder { return 0; } - Future<({String root, String version})> findWindowsSdk() async { + Future findWindowsSdkVersion() async { final roots = {}; for (final view in ['64', '32']) { try { @@ -78,7 +78,7 @@ class WindowsSecpBuilder { // Standalone SDK installers may register only one registry view. } } - final usable = <({String root, String version})>[]; + final usable = []; for (final sdkRoot in roots) { final include = Directory('$sdkRoot/Include'); if (!include.existsSync()) continue; @@ -97,7 +97,7 @@ class WindowsSecpBuilder { 'bin/$version/x64/mt.exe', ]; if (files.every((file) => File('$sdkRoot/$file').existsSync())) { - usable.add((root: sdkRoot, version: version)); + usable.add(version); } } } @@ -107,7 +107,7 @@ class WindowsSecpBuilder { 'and tools. No complete SDK was found under KitsRoot10.', ); } - usable.sort((a, b) => compareVersions(b.version, a.version)); + usable.sort((a, b) => compareVersions(b, a)); return usable.first; } @@ -172,7 +172,7 @@ class WindowsSecpBuilder { 'Install the Visual Studio 2022 C++ build tools and Windows SDK.', ); } - windowsSdk = await findWindowsSdk(); + windowsSdkVersion = await findWindowsSdkVersion(); final bundledCmake = '$visualStudio/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe'; cmake = File(bundledCmake).existsSync() ? bundledCmake : 'cmake'; @@ -223,7 +223,7 @@ class WindowsSecpBuilder { '-A', 'x64', '-DCMAKE_GENERATOR_INSTANCE=$visualStudio', - '-DCMAKE_SYSTEM_VERSION=${windowsSdk.version}', + '-DCMAKE_SYSTEM_VERSION=$windowsSdkVersion', '-DCMAKE_BUILD_TYPE=RelWithDebInfo', '-DBUILD_SHARED_LIBS=ON', '-DSECP256K1_ENABLE_MODULE_RECOVERY=ON', From d2da01bd952a19238966597a326af8a55c513b0b Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Fri, 13 Nov 2026 11:27:37 -0600 Subject: [PATCH 13/13] Tighten setup docs and preflight checks after review Stop documenting -s, which only CI uses, and explain it next to the CI steps that need it. Document that -d ignores native edits in a locked local checkout, that source mode on a Mac needs Xcode 27.0 for every target, the manual Xcode name the setup helper uses, and Android release signing. Load the pinned Xcode metadata only on an Apple Silicon Mac, where it is used, and advise checking the network connection when the download fails. Check for Go and MinGW in the default WSL distribution before configuring Stack Wallet for Windows, unless MWEBD_FETCH=1 or CI applies. Run flutter pub get on Linux before the second override check. Read vswhere output as UTF-8. Make the Xcode ordering tests fail when the ordering logic breaks, and correct a misleading CI comment about symlink permissions. Validation: build_input, native_build_config, configure_native_assets, build_environment, windows_secp_build and xcode_setup tests pass on macOS; native_dependency_contract fails only its app-package metadata test, which needs the app's flutter pub get. The new tests fail when the sort, the setup helper's conflict check, the MWEB check, the Apple Silicon condition, -utf8 or the Linux pub get is removed. Dart analysis with the repo lints reports only an existing info lint; formatting, Bash syntax, workflow YAML and git diff --check passed. The preflight passes on an Apple Silicon Mac for Android prebuilt and iOS source. Nothing was run on Windows or on a Linux host. --- .github/workflows/build.yaml | 12 ++- .github/workflows/test.yaml | 1 + docs/building.md | 32 ++++---- scripts/build_app.sh | 9 ++- scripts/tests/build_environment_test.dart | 87 ++++++++++++++++++++- scripts/tests/native_build_config_test.dart | 7 ++ scripts/tests/windows_secp_build_test.dart | 15 +++- scripts/tests/xcode_setup_test.dart | 4 +- scripts/windows/build_secp256k1.dart | 17 +++- tool/check_build_environment.dart | 56 +++++++++++-- tool/native_dependency_contract.dart | 2 +- 11 files changed, 207 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a9370c65e1..74ee07ff15 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -88,6 +88,8 @@ jobs: - name: Build env: + # With -s (CI only) in Configure app, link the image's system + # jsoncpp and libsecret instead of building them. USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose @@ -263,10 +265,8 @@ jobs: -b "${{ steps.ver.outputs.build_number }}" \ -p windows -a stack_wallet -d -s - # The Actions windows-2022 runner user lacks SeCreateSymbolicLinkPrivilege, - # so link_assets.sh's mklink /D calls either fail or produce broken reparse - # points that Flutter's asset resolver cannot traverse. Replace each of - # the five gitignored asset directories with real copies instead. + # Use real copies of the five gitignored asset directories instead of the + # directory links that link_assets.sh creates. - name: Replace asset symlinks with copies (CI workaround) run: | set -euo pipefail @@ -502,6 +502,8 @@ jobs: - name: Build env: + # With -s (CI only) in Configure app, link the image's system + # jsoncpp and libsecret instead of building them. USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose @@ -571,6 +573,8 @@ jobs: - name: Build env: + # With -s (CI only) in Configure app, link the image's system + # jsoncpp and libsecret instead of building them. USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4528d836a2..25dcc8495d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,6 +44,7 @@ jobs: submodules: recursive # -d selects verified dnix prebuilts for Frost, Tor, Epic and MWC. + # -s (CI only) skips building jsoncpp and libsecret; the image has them. - name: Configure app (prebuilt native assets) run: | cd scripts diff --git a/docs/building.md b/docs/building.md index 31494af06d..83202e9bc9 100644 --- a/docs/building.md +++ b/docs/building.md @@ -54,13 +54,15 @@ 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. +check for missing locks. With `-d`, a checkout that keeps its `dnix.lock` gets +that lock's release library, so local native changes are not built; remove the +lock to build them in `dev` mode. ### Which tools are still required? | Component | Requirements | |---|---| -| The four dnix packages, source mode | [Nix](https://nixos.org/download/); on Windows, Nix inside WSL2; for Apple targets, Xcode **27.0**, as pinned by the packages | +| The four dnix packages, source mode | [Nix](https://nixos.org/download/); on Windows, Nix inside WSL2; on a Mac, Xcode **27.0** for every target, including `flutter test`, as pinned by the packages | | The four dnix packages, `-d` | No Nix or package-specific Rust/C toolchain needed for these libraries | | Flutter and platform plugins | Target SDKs and the platform's C/C++ build tools in either mode | | OpenAlias / DNSSEC (every app) | Rustup on the Flutter host in both modes; `dnssec_resolver` builds from source with Rust **1.90.0** | @@ -68,7 +70,7 @@ check for missing locks. | 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 | +| Linux secure storage | The dependencies to build libsecret and JsonCPP | `-d` applies to the dnix packages, not every native library in the application. Their old host Rust toolchain lists, `cbindgen`, `cargo-lipo`, and manually built @@ -99,8 +101,7 @@ sudo apt-get install build-essential clang cmake ninja-build pkg-config \ libjsoncpp-dev libsecret-1-dev ``` -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: +`build_app.sh` builds JsonCPP and libsecret, which also needs: ```sh sudo apt-get install meson libgirepository1.0-dev valac xsltproc \ @@ -188,13 +189,13 @@ to its own settings. 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` +2. Expand the archive in Finder. Rename the extracted app to `Xcode-27.0-27A266a.app` **before** moving it into `/Applications`, preserving your existing Xcode. 3. Open a separate Terminal window and finish setup: ```sh - sudo -k /Applications/Xcode-27.0.app/Contents/Developer/usr/bin/xcodebuild -runFirstLaunch - DEVELOPER_DIR=/Applications/Xcode-27.0.app/Contents/Developer xcodebuild -version + sudo -k /Applications/Xcode-27.0-27A266a.app/Contents/Developer/usr/bin/xcodebuild -runFirstLaunch + DEVELOPER_DIR=/Applications/Xcode-27.0-27A266a.app/Contents/Developer xcodebuild -version ``` The first command installs Apple's components and accepts the Xcode license. @@ -207,7 +208,7 @@ In your normal build terminal, select 27.0 for Flutter and Xcode commands in tha shell only. Substitute the path printed by the helper (`xcodes` may name the bundle `Xcode-27.0.0.app`): ```sh -export DEVELOPER_DIR=/Applications/Xcode-27.0.app/Contents/Developer +export DEVELOPER_DIR=/Applications/Xcode-27.0-27A266a.app/Contents/Developer xcodebuild -version flutter doctor ``` @@ -321,7 +322,7 @@ compile. On Windows you can also use All four configuration arguments are required: ```sh -bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 flutter pub get dart tool/check_native_overrides.dart stack_wallet flutter build linux --release @@ -332,7 +333,7 @@ script's confirmation before proceeding. To use verified dnix prebuilts, add `-d` to the configuration command. For example: ```sh -bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s -d +bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -d ``` | Option | Meaning | @@ -342,7 +343,6 @@ bash scripts/build_app.sh -a stack_wallet -p linux -v 1.2.3 -b 123 -s -d | `-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 | @@ -357,8 +357,12 @@ Choose `-p` for your target and then run the corresponding Flutter build: | 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 +Android release builds need `android/key.properties` pointing at your keystore, +with `keyAlias`, `keyPassword`, `storeFile` and `storePassword`. + +Configuration checks Rustup, source-mode Nix/WSL/Xcode requirements, the +Windows secp256k1 tools, and the WSL tools for Stack Wallet's Windows MWEB build +before replacing generated files. A dnix requirement failure explains how to use `-d` instead; `-d` still requires Rustup for DNSSEC. The icon/splash `dart run` commands use only those executables' dependency closures and do **not** run the app's native hooks. App hooks run with later diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 231f175095..253cc58435 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -6,11 +6,11 @@ source "$(dirname -- "${BASH_SOURCE[0]}")/env.sh" # Function to display usage. usage() { - echo "Usage: $0 -v -b -p -a [-d] [-i] [-f] [-s] [-w ]" + echo "Usage: $0 -v -b -p -a [-d] [-i] [-f] [-w ]" echo " Default: build dnix libraries from source with Nix and verify their hashes." echo " -d: use verified dnix prebuilts; other packages may still build from source." echo " -i: skip platform native dependencies (secure storage and secp256k1)." - echo " -f: build Isar from source. -s: use system Linux secure-storage libraries." + echo " -f: build Isar from source." echo " -w: dnix WSL distribution (default: DNIX_WSL_DISTRIBUTION or WSL default)." exit "${1:-1}" } @@ -147,9 +147,12 @@ dart "${APP_PROJECT_ROOT_DIR}/tool/configure_native_assets.dart" \ if [[ "$APP_BUILD_PLATFORM" != "linux" ]]; then # run icon and image generators after project config has completed for non linux "${APP_PROJECT_ROOT_DIR}/scripts/app_config/shared/asset_generators.sh" "${APP_BUILD_PLATFORM}" +else + # Linux skips asset generation; resolve here so the check below is current. + (cd "${APP_PROJECT_ROOT_DIR}" && flutter pub get) fi -# Asset generation can resolve dependencies; inspect the resulting checkouts too. +# Inspect the checkouts that the configured dependencies resolve to. dart "${APP_PROJECT_ROOT_DIR}/tool/check_native_overrides.dart" "$APP_NAMED_ID" # Build platform native deps that are not provided by pub packages. The crypto diff --git a/scripts/tests/build_environment_test.dart b/scripts/tests/build_environment_test.dart index 090da6570b..d8406332a1 100644 --- a/scripts/tests/build_environment_test.dart +++ b/scripts/tests/build_environment_test.dart @@ -18,7 +18,9 @@ class HostFixture { bool rustupOnPath = true; bool rustupInHome = false; bool nix = true; + bool mwebTools = true; int wslExit = 0; + final extraVariables = {}; String selected = '/Applications/Xcode.app/Contents/Developer'; final applications = ['/Applications/Xcode.app']; final versions = { @@ -37,6 +39,7 @@ class HostFixture { 'HOME': '/Users/test', 'USERPROFILE': r'C:\Users\test', 'DEVELOPER_DIR': '/Applications/Caller-selected.app/Contents/Developer', + ...extraVariables, }, listXcodes: () async => applications.toList(), run: (executable, arguments, environment) async { @@ -58,6 +61,9 @@ class HostFixture { return ProcessResult(0, 0, 'nix fixture', ''); } if (executable == 'wsl.exe') { + if (arguments.contains('bash')) { + return ProcessResult(0, mwebTools ? 0 : 1, '', ''); + } return ProcessResult(0, wslExit, 'nix fixture', ''); } if (executable == '/usr/bin/xcode-select') { @@ -216,6 +222,83 @@ void main() { }); } + test( + 'only Apple source builds on Apple Silicon need Xcode metadata', + () async { + for (final (os, architecture, error) in [ + ('linux', 'x64', 'Apple targets require a macOS build host.'), + ('windows', 'x64', 'Apple targets require a macOS build host.'), + ('macos', 'x64', 'dnix source builds require an Apple Silicon Mac.'), + ]) { + fixture.os = os; + fixture.architecture = architecture; + final errors = await checkBuildEnvironment( + host: fixture.host, + platform: 'ios', + app: 'stack_wallet', + mode: 'source', + ); + expect(errors.first, startsWith(error)); + } + fixture.os = 'macos'; + fixture.architecture = 'arm64'; + expect( + () => checkBuildEnvironment( + host: fixture.host, + platform: 'ios', + app: 'stack_wallet', + mode: 'source', + ), + throwsArgumentError, + ); + }, + ); + + test( + 'Stack Wallet on Windows checks the WSL MWEB tools in both modes', + () async { + fixture.os = 'windows'; + fixture.mwebTools = false; + for (final mode in ['source', 'prebuilt']) { + final errors = await fixture.check(platform: 'windows', mode: mode); + expect(errors.single, contains('x86_64-w64-mingw32-gcc')); + expect(errors.single, contains('MWEBD_FETCH=1')); + } + final probe = fixture.calls.firstWhere( + (call) => call.arguments.contains('bash'), + ); + expect(probe.executable, 'wsl.exe'); + expect(probe.arguments, isNot(contains('--distribution'))); + expect( + probe.arguments, + containsAllInOrder(['--exec', 'bash', '-l', '-c']), + ); + }, + ); + + test('MWEBD_FETCH, CI and other apps skip the MWEB tools check', () async { + fixture.os = 'windows'; + fixture.mwebTools = false; + expect( + await fixture.check( + platform: 'windows', + app: 'campfire', + mode: 'prebuilt', + ), + isEmpty, + ); + fixture.extraVariables['MWEBD_FETCH'] = '1'; + expect(await fixture.check(platform: 'windows', mode: 'prebuilt'), isEmpty); + fixture.extraVariables + ..remove('MWEBD_FETCH') + ..['CI'] = 'true'; + expect(await fixture.check(platform: 'windows', mode: 'prebuilt'), isEmpty); + expect( + fixture.calls.where((call) => call.arguments.contains('bash')), + isEmpty, + ); + }); + for (final os in ['linux', 'macos']) { test('Windows configuration on a $os host is rejected', () async { fixture.os = os; @@ -229,7 +312,7 @@ void main() { } test('global GA Xcode wins over an alphabetically earlier beta', () async { - fixture.applications.insert(0, '/Applications/Xcode-27.0-Beta.app'); + fixture.applications.add('/Applications/Xcode-27.0-Beta.app'); fixture.versions['/Applications/Xcode-27.0-Beta.app/Contents/Developer'] = 'Xcode 27.0\nBuild version 27A111b\n'; expect(await fixture.check(), isEmpty); @@ -260,7 +343,7 @@ void main() { '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.applications.add('/Applications/Xcode-27.0-Beta.app'); fixture.versions['/Applications/Xcode-27.0-Beta.app/Contents/Developer'] = 'Xcode 27.0\nBuild version 27A111b\n'; final errors = await fixture.check(); diff --git a/scripts/tests/native_build_config_test.dart b/scripts/tests/native_build_config_test.dart index ae9d4910cd..ab215d1733 100644 --- a/scripts/tests/native_build_config_test.dart +++ b/scripts/tests/native_build_config_test.dart @@ -98,6 +98,10 @@ printf '0000000000000000000000000000000000000000\n' final dart = Platform.resolvedExecutable.replaceAll("'", "'\\''"); script('bin/dart', "exec '$dart' \"\$@\""); script('bin/sha256sum', "printf 'fixture-hash mwebd.exe\\n'"); + script( + 'bin/flutter', + r'''printf '%s\n' "$*" >> "$APP_PROJECT_ROOT_DIR/flutter-calls"''', + ); // build_app.sh configures Windows only from Git Bash. script('bin/uname', r''' if [ "${1:-}" = -s ] && [ -n "${DNIX_TEST_UNAME:-}" ]; then @@ -291,6 +295,9 @@ void main(List args) { 'secure_storage_deps', 'secp256k1', ]); + expect(File(p.join(root.path, 'flutter-calls')).readAsLinesSync(), [ + 'pub get', + ]); expect( File(p.join(root.path, 'native-cwd')).readAsStringSync().trim(), Directory(p.join(root.path, 'scripts/linux')) diff --git a/scripts/tests/windows_secp_build_test.dart b/scripts/tests/windows_secp_build_test.dart index 89ae1ceb65..be724bada1 100644 --- a/scripts/tests/windows_secp_build_test.dart +++ b/scripts/tests/windows_secp_build_test.dart @@ -1,4 +1,5 @@ // Run with: dart scripts/tests/windows_secp_build_test.dart +import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; @@ -17,6 +18,7 @@ void main() { String? failAt; bool missingDll = false; bool cleanupLocked = false; + Encoding? vswhereEncoding; test('secp pin matches the resolved coinlib Windows recipe', () async { // Resolve the dependency selected by pub, not a copied pin or cache path. @@ -66,6 +68,7 @@ void main() { failAt = null; missingDll = false; cleanupLocked = false; + vswhereEncoding = null; for (final entry in { 'Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe': 'fixture', @@ -90,11 +93,14 @@ void main() { } directory.deleteSync(recursive: true); }, - run: (executable, args, {workingDirectory}) async { + run: (executable, args, {workingDirectory, stdoutEncoding}) async { calls.add((executable: executable, args: args, cwd: workingDirectory)); ProcessResult result([String out = '', int status = 0]) => ProcessResult(0, status, out, status == 0 ? '' : 'fixture failure'); - if (executable.endsWith('vswhere.exe')) return result(visualStudio); + if (executable.endsWith('vswhere.exe')) { + vswhereEncoding = stdoutEncoding; + return result(visualStudio); + } if (executable == 'reg.exe') { // A standalone SDK registered only in the 32-bit registry view. return args.contains('/reg:64') @@ -144,6 +150,11 @@ void main() { await builder().check(); expect(Directory('${root.path}/build').existsSync(), isFalse); expect(operation('fetch'), isEmpty); + expect( + calls.singleWhere((c) => c.executable.endsWith('vswhere.exe')).args, + contains('-utf8'), + ); + expect(vswhereEncoding, utf8); expect(calls.any((c) => c.executable == 'cmake'), isFalse); expect( calls.any( diff --git a/scripts/tests/xcode_setup_test.dart b/scripts/tests/xcode_setup_test.dart index c08bcc0347..51a75646d5 100644 --- a/scripts/tests/xcode_setup_test.dart +++ b/scripts/tests/xcode_setup_test.dart @@ -176,7 +176,7 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" '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"); + xcode("Xcode-27.0-user's.app"); File('${bin.path}/xcodes').deleteSync(); final result = await runInstaller(); expect(result.exitCode, 0, reason: '${result.stderr}'); @@ -185,7 +185,7 @@ cp -R "$SPARE" "$4/Xcode-27.0.0.app" 'setup', 'checked', ]); - expect(result.stdout, contains("Xcode-27.0 user's.app")); + expect(result.stdout, contains("Xcode-27.0-user's.app")); }, ); diff --git a/scripts/windows/build_secp256k1.dart b/scripts/windows/build_secp256k1.dart index 96624b1ef7..575e1a78df 100644 --- a/scripts/windows/build_secp256k1.dart +++ b/scripts/windows/build_secp256k1.dart @@ -1,4 +1,5 @@ // 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 @@ -7,13 +8,20 @@ typedef CommandRunner = Future Function( String executable, List arguments, { String? workingDirectory, + Encoding? stdoutEncoding, }); Future _run( String executable, List arguments, { String? workingDirectory, -}) => Process.run(executable, arguments, workingDirectory: workingDirectory); + Encoding? stdoutEncoding, +}) => Process.run( + executable, + arguments, + workingDirectory: workingDirectory, + stdoutEncoding: stdoutEncoding ?? systemEncoding, +); void _removeWork(Directory directory) => directory.deleteSync(recursive: true); @@ -115,6 +123,7 @@ class WindowsSecpBuilder { String executable, List arguments, { String? workingDirectory, + Encoding? stdoutEncoding, }) async { final ProcessResult result; try { @@ -122,6 +131,7 @@ class WindowsSecpBuilder { executable, arguments, workingDirectory: workingDirectory, + stdoutEncoding: stdoutEncoding, ); } on ProcessException catch (error) { throw SecpBuildException('Cannot run $executable: ${error.message}'); @@ -152,7 +162,10 @@ class WindowsSecpBuilder { 'including CMake tools and a Windows SDK (vswhere.exe is missing).', ); } + // Without -utf8, vswhere writes the console code page, which Dart would + // misread for a non-ASCII installation path. final result = await checked(vswhere, [ + '-utf8', '-latest', '-products', '*', @@ -162,7 +175,7 @@ class WindowsSecpBuilder { 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath', - ]); + ], stdoutEncoding: utf8); visualStudio = result.stdout.toString().trim(); final toolsVersion = File( '$visualStudio/VC/Auxiliary/Build/Microsoft.VCToolsVersion.default.txt', diff --git a/tool/check_build_environment.dart b/tool/check_build_environment.dart index 45bf9e3937..c8feeff2a9 100644 --- a/tool/check_build_environment.dart +++ b/tool/check_build_environment.dart @@ -1,4 +1,5 @@ // Run directly with Dart before build_app.sh changes any generated files. +import 'dart:async'; import 'dart:ffi'; import 'dart:io'; @@ -82,6 +83,17 @@ fi nix --version '''; +/// Only Apple source builds on an Apple Silicon Mac check the pinned Xcode. +bool needsXcodeRequirement( + BuildEnvironment host, { + required String platform, + required String mode, +}) => + mode == 'source' && + {'ios', 'macos'}.contains(platform) && + host.os == 'macos' && + host.architecture == 'arm64'; + Future> checkBuildEnvironment({ required BuildEnvironment host, required String platform, @@ -96,8 +108,8 @@ Future> checkBuildEnvironment({ 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) { + if (needsXcodeRequirement(host, platform: platform, mode: mode) && + xcodeRequirement == null) { throw ArgumentError( 'Apple source builds need the native Xcode requirement.', ); @@ -131,6 +143,34 @@ Future> checkBuildEnvironment({ 'The hooks install their pinned toolchains automatically.', ); } + // Stack Wallet's Windows configuration builds mwebd.exe with Go and MinGW in + // the default WSL distribution (tool/build_standalone_mwebd_windows.dart), + // unless MWEBD_FETCH=1 downloads it or CI builds it natively. + if (app == 'stack_wallet' && + platform == 'windows' && + host.variables['MWEBD_FETCH'] != '1' && + host.variables['CI'] != 'true') { + final result = await host.command( + 'wsl.exe', + [ + '--cd', + '/', + '--exec', + 'bash', + '-l', + '-c', + 'command -v go && command -v x86_64-w64-mingw32-gcc', + ], + environment: {...host.variables, 'WSL_UTF8': '1'}, + ); + if (result?.exitCode != 0) { + errors.add( + "Stack Wallet's Windows MWEB build needs Go and x86_64-w64-mingw32-gcc " + 'in the default WSL distribution. Install Go and mingw-w64 there, or ' + 'set MWEBD_FETCH=1 to download the published build.', + ); + } + } if (mode == 'prebuilt') return errors; // Windows hooks run Nix in the default or requested WSL distribution. @@ -276,8 +316,9 @@ Future main(List args) async { return; } try { + final host = BuildEnvironment.current(); XcodeRequirement? requirement; - if (args[2] == 'source' && {'ios', 'macos'}.contains(args[0])) { + if (needsXcodeRequirement(host, platform: args[0], mode: args[2])) { try { requirement = await loadXcodeRequirement( File.fromUri(Platform.script).parent.parent, @@ -286,14 +327,19 @@ Future main(List args) async { } catch (error) { stderr.writeln('Cannot determine the required Xcode: $error'); stderr.writeln( - 'See docs/building.md for dependency setup instructions.', + error is SocketException || + error is HttpException || + error is TlsException || + error is TimeoutException + ? 'Check your network connection and retry.' + : 'See docs/building.md for dependency setup instructions.', ); exitCode = 1; return; } } final errors = await checkBuildEnvironment( - host: BuildEnvironment.current(), + host: host, platform: args[0], app: args[1], mode: args[2], diff --git a/tool/native_dependency_contract.dart b/tool/native_dependency_contract.dart index 85e4bfce19..00f4979182 100644 --- a/tool/native_dependency_contract.dart +++ b/tool/native_dependency_contract.dart @@ -206,7 +206,7 @@ Future> _readMetadata( if (response.statusCode != HttpStatus.ok) { throw HttpException( 'Unable to read pinned dnix metadata ' - '(HTTP ${response.statusCode}). Run flutter pub get and retry.', + '(HTTP ${response.statusCode}).', uri: uri, ); }