diff --git a/.github/actions/build-plugin/action.yml b/.github/actions/build-plugin/action.yml index 10b9b30a..8bd526dc 100644 --- a/.github/actions/build-plugin/action.yml +++ b/.github/actions/build-plugin/action.yml @@ -25,7 +25,7 @@ runs: shell: sh run: | rustc --print=target-list - - name: Build for Windows and Linux + - name: Build for Windows and Linux and Android shell: sh env: CI: 1 diff --git a/.github/workflows/android_builds.yml b/.github/workflows/android_builds.yml new file mode 100644 index 00000000..fc56f35d --- /dev/null +++ b/.github/workflows/android_builds.yml @@ -0,0 +1,52 @@ +name: 🤖 Build Backstitch for Android +on: + workflow_call: + +jobs: + android: + runs-on: "ubuntu-24.04" + name: 🔨 Build ${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + arch: ["aarch64-linux-android", "x86_64-linux-android"] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - id: dotenv + uses: falti/dotenv-action@v1.1.5 + with: + path: build.env + keys-case: bypass + - name: Select pinned Android NDK + shell: bash + env: + NDK_VERSION: ${{ steps.dotenv.outputs.ANDROID_NDK_VERSION }} + run: | + set -eu + ndk="$ANDROID_HOME/ndk/$NDK_VERSION" + if [ ! -d "$ndk" ]; then + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" + fi + # The runner presets these to its own default NDK, which is older than the pinned one. + echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV" + + - name: Build + uses: ./.github/actions/build-plugin + with: + arch: ${{ matrix.arch }} + + # TODO: Remove the necessity for this workaround somehow; this is just here so we don't accidentally clobber the signed MacOS framework files. + - name: Remove MacOS framework + shell: bash + run: | + rm -rf ./build/backstitch/bin/libbackstitch_rust_core.macos.framework + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: backstitch-build-android-${{ matrix.arch }} + path: ./build/backstitch/* + if-no-files-found: error diff --git a/.github/workflows/runner.yml b/.github/workflows/runner.yml index ecf96df3..b64e1262 100644 --- a/.github/workflows/runner.yml +++ b/.github/workflows/runner.yml @@ -51,9 +51,13 @@ jobs: name: 🐧 Build Backstitch for Linux uses: ./.github/workflows/linux_builds.yml + android-plugin-builds: + name: 🤖 Build Backstitch for Android + uses: ./.github/workflows/android_builds.yml + merge-plugin-builds: name: 🧩 Merge Backstitch Builds - needs: [macos-plugin-builds, windows-plugin-builds, linux-plugin-builds] + needs: [macos-plugin-builds, windows-plugin-builds, linux-plugin-builds, android-plugin-builds] uses: ./.github/workflows/merge_builds.yml compose-projects: diff --git a/.gitignore b/.gitignore index 6c77d1de..a54df400 100644 --- a/.gitignore +++ b/.gitignore @@ -342,5 +342,7 @@ cmake-build-*/ .cargo/dev-cert.p12 .cargo/*.p12 .cargo/*.base64 +# generated by `just _build-plugin-android` +.cargo/android.toml *.obj.import rustc-ice-*.txt \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c28cd214..62371461 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -422,6 +422,7 @@ dependencies = [ "notify-debouncer-full", "open", "openidconnect", + "openssl", "rand 0.10.3", "rayon", "regex", @@ -1461,9 +1462,8 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fjall" -version = "3.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8efbd0317c570d3bc4f498f44644d908e7dbac8138f4e083cc853baea25c8c65" +version = "3.1.8" +source = "git+https://github.com/nikitalita/fjall?branch=handle-unsupported-file-lock#506da8dc0f1c39982e25785deb11a46e7b840c87" dependencies = [ "byteorder-lite", "byteview", @@ -2057,6 +2057,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -3014,6 +3030,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.117" @@ -3022,6 +3047,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -3622,10 +3648,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3636,6 +3664,7 @@ dependencies = [ "serde_json", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index fb0fa91c..ccdddf18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ tokio-util = "0.7.18" sys-locale = "0.3.2" blake3 = { version = "1.8.4", features = ["serde"] } wincode = { version = "0.6.0", features = ["derive"] } -fjall = "3.1.4" +fjall = { git = "https://github.com/nikitalita/fjall", branch = "handle-unsupported-file-lock" } # Needed for Android build jwalk = "0.9.0" thiserror = "2.0.21" indexmap = "2.14" @@ -67,7 +67,7 @@ url = "2.5.8" open = "5.4.0" axum = "0.8.9" html-escape = "0.3.0" -reqwest = { version = "0.13.5", features = ["json"] } +reqwest = { version = "0.13.5", features = ["json", "native-tls"] } semver = { version = "1.0.28", features = ["serde"] } async-trait = "0.1.91" secrecy = "0.10.3" diff --git a/backstitch/Cargo.toml b/backstitch/Cargo.toml index c0244b2a..74756316 100644 --- a/backstitch/Cargo.toml +++ b/backstitch/Cargo.toml @@ -66,3 +66,10 @@ static_vcruntime = { workspace = true } [dev-dependencies] tempfile = { workspace = true } + +# `samod/tungstenite` forces `native-tls`, which resolves to openssl-sys on Android. +# There is no system OpenSSL to link against there, so we have to build +# it from source with the NDK toolchain. +[target.'cfg(target_os = "android")'.dependencies] +openssl = { version = "0.10", features = ["vendored"] } +reqwest = { version = "0.13.5", features = ["json", "native-tls-vendored"] } diff --git a/backstitch_godot/src/interop/extension.rs b/backstitch_godot/src/interop/extension.rs index e27895bf..2ee00133 100644 --- a/backstitch_godot/src/interop/extension.rs +++ b/backstitch_godot/src/interop/extension.rs @@ -25,6 +25,8 @@ unsafe impl ExtensionLibrary for MyExtension { fn on_stage_init(level: InitStage) { if level == InitStage::Scene { initialize_tracing(); + #[cfg(target_os = "android")] + initialize_android_cert_dir(); tracing::info!("** on_level_init: Scene"); Engine::singleton().register_singleton("GodotProject", &GodotProject::new_alloc()); let loader = BackstitchResourceLoader::new_gd(); @@ -73,6 +75,42 @@ unsafe impl ExtensionLibrary for MyExtension { } } +/// Point OpenSSL at Android's CA store. +/// +/// We build OpenSSL from source for Android, so its compiled-in `OPENSSLDIR` refers to a path on +/// the build machine, and `openssl-probe` only looks for Termux and generic Linux locations. Left +/// alone, neither finds any root certificates and every TLS handshake fails to verify the peer. +/// Both directories below use OpenSSL's hashed-`CApath` layout, so `SSL_CERT_DIR` is enough. +#[cfg(target_os = "android")] +fn initialize_android_cert_dir() { + use std::path::Path; + + const CERT_DIRS: [&str; 2] = [ + // Updatable CA store, present from API 34 on and preferred when available. + "/apex/com.android.conscrypt/cacerts", + "/system/etc/security/cacerts", + ]; + + if std::env::var_os("SSL_CERT_DIR").is_some() || std::env::var_os("SSL_CERT_FILE").is_some() { + return; + } + + let Some(cert_dir) = CERT_DIRS + .iter() + .copied() + .find(|dir| Path::new(dir).is_dir()) + else { + tracing::warn!( + "no Android CA store found; TLS connections will not be able to verify peers" + ); + return; + }; + + // Sound because extension init runs before we spawn any threads that could read the env. + unsafe { std::env::set_var("SSL_CERT_DIR", cert_dir) }; + tracing::info!("using Android CA store at {cert_dir}"); +} + fn unregister_singleton(singleton_name: &str) { if Engine::singleton().has_singleton(singleton_name) { let my_singleton = Engine::singleton().get_singleton(singleton_name); diff --git a/build.env b/build.env index 336e6e74..d9743833 100644 --- a/build.env +++ b/build.env @@ -18,6 +18,12 @@ GODOT_FORMATTERS_REF=master MINIMUM_GODOT=4.7-stable RECOMMENDED_GODOT=4.7.1-stable +# Android +# Keep in sync with platform/android/detect.py#get_ndk_version() +ANDROID_NDK_VERSION=29.0.14206865 +# Keep in sync with platform/android/export/export_plugin.cpp#DEFAULT_MIN_SDK_VERSION +ANDROID_MIN_SDK=24 + # Launchers below this won't be able to download the release MINIMUM_LAUNCHER=2.1.1 # Launcher version included in the project builds diff --git a/justfile b/justfile index a655a9fb..c4411465 100644 --- a/justfile +++ b/justfile @@ -407,6 +407,129 @@ _build-plugin-single-arch architecture profile tracing_support: (_build-plugin a build/backstitch/bin/backstitch_godot.pdb fi +# Build the Rust plugin for a single Android target using the NDK toolchain. +_build-plugin-android architecture profile tracing_support: _make-plugin-dir + #!/usr/bin/env python3 + import os + import platform + import shutil + import subprocess + import sys + from pathlib import Path + + architecture = "{{architecture}}" + profile = "{{profile}}" + + # Godot's Android ABI names, used for the artifact suffix and the .gdextension keys. + GODOT_ARCH = { + "aarch64-linux-android": "arm64", + "armv7-linux-androideabi": "arm32", + "i686-linux-android": "x86_32", + "x86_64-linux-android": "x86_64", + } + # The NDK clang wrappers don't always match the Rust triple; armv7 is the odd one out. + NDK_PREFIX = { + "aarch64-linux-android": "aarch64-linux-android", + "armv7-linux-androideabi": "armv7a-linux-androideabi", + "i686-linux-android": "i686-linux-android", + "x86_64-linux-android": "x86_64-linux-android", + } + + if architecture not in GODOT_ARCH: + sys.exit(f"Unsupported Android target: {architecture}") + + is_windows = platform.system().lower().startswith("win") + + def default_sdk_home(): + home = Path.home() + if sys.platform == "darwin": + return home / "Library" / "Android" / "sdk" + if is_windows: + return Path(os.environ.get("LOCALAPPDATA", str(home))) / "Android" / "Sdk" + return home / "Android" / "Sdk" + + sdk_home = Path( + os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") or default_sdk_home() + ) + ndk_version = os.environ["ANDROID_NDK_VERSION"] + ndk_root = Path( + os.environ.get("ANDROID_NDK_ROOT") + or os.environ.get("ANDROID_NDK_HOME") + or (sdk_home / "ndk" / ndk_version) + ) + if not ndk_root.is_dir(): + sys.exit( + f"**** Android NDK not found at {ndk_root}\n" + "**** Set ANDROID_HOME to your SDK location, or ANDROID_NDK_ROOT to the NDK itself.\n" + f"**** HINT: install it with 'sdkmanager \"ndk;{ndk_version}\"'" + ) + + host_name = platform.system().lower() + if host_name.startswith("win"): + host_name = "win" + prebuilt = ndk_root / "toolchains" / "llvm" / "prebuilt" + bin_dir = next( + (entry / "bin" for entry in sorted(prebuilt.iterdir()) if entry.name.startswith(host_name)), + None, + ) + if bin_dir is None or not bin_dir.is_dir(): + sys.exit(f"Could not find an LLVM prebuilt toolchain for '{host_name}' in {prebuilt}") + + exe_suffix = ".exe" if is_windows else "" + linker_suffix = ".cmd" if is_windows else "" + min_sdk = os.environ["ANDROID_MIN_SDK"] + ar = (bin_dir / ("llvm-ar" + exe_suffix)).as_posix() + ranlib = (bin_dir / ("llvm-ranlib" + exe_suffix)).as_posix() + + def linker_for(triple): + return (bin_dir / (NDK_PREFIX[triple] + min_sdk + "-clang" + linker_suffix)).as_posix() + + # This goes in its own file rather than .cargo/config.toml, which is checked in and holds + # the Linux cross-linkers. Cargo layers `--config ` on top of the checked-in config. + sections = [ + f'[target.{triple}]\nar = "{ar}"\nlinker = "{linker_for(triple)}"\n' for triple in GODOT_ARCH + ] + cargo_config = Path(".cargo/android.toml").resolve() + cargo_config.parent.mkdir(parents=True, exist_ok=True) + cargo_config.write_text("\n".join(sections)) + + env = os.environ.copy() + env["PATH"] = str(bin_dir) + (";" if is_windows else ":") + env.get("PATH", "") + env["ANDROID_HOME"] = str(sdk_home) + env["ANDROID_SDK_ROOT"] = str(sdk_home) + env["ANDROID_NDK_ROOT"] = str(ndk_root) + env["ANDROID_NDK_HOME"] = str(ndk_root) + # cc-rs and openssl-src read these. The NDK ships no plain `ar` or `ranlib`, only the + # llvm-prefixed ones, so leaving these unset makes the native builds fail. + env["CC_" + architecture] = linker_for(architecture) + env["AR_" + architecture] = ar + env["RANLIB_" + architecture] = ranlib + + subprocess.check_call(["rustup", "target", "add", architecture]) + + cargo_cmd = [ + "cargo", + "--config", + str(cargo_config), + "build", + f"--profile={profile}", + f"--target={architecture}", + ] + if "{{tracing_support}}" == "tokio-console": + env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + " --cfg tokio_unstable").strip() + cargo_cmd += ["--features", "tokio-console"] + + print("**** Building Backstitch for Android: " + " ".join(cargo_cmd)) + subprocess.check_call(cargo_cmd, env=env) + + out_dir = Path("build/backstitch/bin") + out_dir.mkdir(parents=True, exist_ok=True) + # Godot's Android exporter rejects .so names that don't start with "lib". + shutil.copy( + Path("target") / architecture / profile / "libbackstitch_godot.so", + out_dir / f"libbackstitch_godot.android.{GODOT_ARCH[architecture]}.so", + ) + # Write plugin.cfg and Backstitch.gdextension _configure-backstitch: _make-plugin-dir #!/usr/bin/env python3 @@ -459,9 +582,11 @@ _configure-backstitch: _make-plugin-dir windows.editor.x86_64 = "bin/backstitch_godot.windows.x86_64-pc-windows-msvc.dll" windows.editor.arm64 = "bin/backstitch_godot.windows.aarch64-pc-windows-msvc.dll" macos.editor = "bin/libbackstitch_godot.macos.framework/libbackstitch_godot.dylib" + android.editor.arm64 = "bin/libbackstitch_godot.android.arm64.so" + android.editor.x86_64 = "bin/libbackstitch_godot.android.x86_64.so" """) -# Build the plugin and output it to the plugin build dir. For MacOS multi-arch, use architecture=all-apple-darwin to build all architectures. +# Build the plugin and output it to the plugin build dir. For MacOS multi-arch, use architecture=all-apple-darwin to build all architectures. For Android, use architecture=all-android to build arm64 and x86_64. [parallel] [arg('profile', pattern='release|debug')] [arg('tracing_support', pattern='none|tokio-console')] @@ -478,6 +603,19 @@ build-backstitch profile architecture=(default_arch) tracing_support="none": _co profile="release_debug" fi + if [[ "{{architecture}}" = "all-android" ]] ; then + just _build-plugin-android "aarch64-linux-android" "$profile" "{{tracing_support}}" + just _build-plugin-android "x86_64-linux-android" "$profile" "{{tracing_support}}" + exit 0 + fi + + # Android targets need the NDK toolchain and a "lib"-prefixed artifact name. + case "{{architecture}}" in + *-linux-android|*-linux-androideabi) + just _build-plugin-android "{{architecture}}" "$profile" "{{tracing_support}}" + exit 0 ;; + esac + just _build-plugin-single-arch "{{architecture}}" "$profile" "{{tracing_support}}" # Reset the Godot repository, removing the linked module and resetting the repo state.