Skip to content

feat(Android): implement the native VPN tunnel #14

feat(Android): implement the native VPN tunnel

feat(Android): implement the native VPN tunnel #14

Workflow file for this run

name: CI
# Validation for every push to main and every pull request. Build/sign/publish live in
# release.yml (tag-triggered) - this workflow never releases anything, it only catches
# breakage before it lands: type errors, lint, Rust formatting, and clippy.
on:
push:
branches: [ main ]
pull_request:
permissions:
contents: read
# Supersede in-flight runs for the same ref (PR pushes), but let main runs finish.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
frontend:
name: Frontend (check, build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
# `azeroth check` is the combined gate: typecheck + lint in one pass, which is
# why there is no separate lint step.
- name: Check (typecheck + lint)
run: npm run check
- name: Build
run: npm run build
rust:
name: Rust (rustfmt, clippy)
# The backend is platform-dispatched (src-tauri/src/platform): the Windows code is
# behind cfg(windows) and other targets compile the `unsupported` fallback (Linux
# gets its real backend in a later phase). So clippy runs on every desktop OS - a
# cross-platform break fails here the moment it lands, not only when someone builds
# on that OS.
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
# Tauri's Linux crates (webkit2gtk/gtk/soup/javascriptcore -sys) need their dev
# packages present for clippy to even compile the dependency tree.
- name: Install Linux Tauri dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev
- name: Provide a placeholder frontend dist
shell: bash
run: |
# tauri's generate_context! reads frontendDist (../dist) at compile
# time, so the directory must exist for clippy/check to compile. Its
# contents don't affect Rust lints, so a stub is enough - this job
# deliberately does not build the real frontend.
mkdir -p dist
printf '<!doctype html><title>ci</title>' > dist/index.html
- name: rustfmt
working-directory: src-tauri
run: cargo fmt --all --check
- name: clippy
working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings
linux-build:
name: Linux build (deb + AppImage)
# A full tauri build is heavy, so it runs on pushes to main only - the rust matrix
# above already proves cross-platform compilation cheaply on every PR. This proves
# the Linux app actually BUNDLES, in exactly the shape release.yml ships: the .deb
# plus the AppImage, which is the Linux updater target.
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install Linux Tauri dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev
- run: npm ci
- name: Fetch the Linux Xray core
run: node scripts/fetch-core.mjs --target linux-64
# createUpdaterArtifacts signs the build, so the updater key is needed even for
# a throwaway CI bundle. Secrets are only present on same-repo pushes, which is
# exactly where this job runs.
- name: Build the .deb + AppImage
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --bundles deb,appimage
macos-build:
name: macOS build (universal app + dmg)
# Heavy; pushes to main only. Proves the macOS app bundles in exactly the shape
# release.yml ships - a UNIVERSAL build whose Xray core is two arch binaries fused
# with lipo - so that path is exercised on a push instead of first at tag time.
# UNSIGNED: code-signing + notarization need a paid Apple Developer account.
if: github.event_name == 'push'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: npm ci
- name: Fetch and fuse the macOS Xray cores
run: |
node scripts/fetch-core.mjs --target macos-arm64-v8a
mv src-tauri/assets/app-xray "$RUNNER_TEMP/app-xray-arm64"
node scripts/fetch-core.mjs --target macos-64
mv src-tauri/assets/app-xray "$RUNNER_TEMP/app-xray-x86_64"
lipo -create "$RUNNER_TEMP/app-xray-arm64" "$RUNNER_TEMP/app-xray-x86_64" \
-output src-tauri/assets/app-xray
chmod +x src-tauri/assets/app-xray
lipo -info src-tauri/assets/app-xray
- name: Build the universal .app + .dmg (unsigned)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --target universal-apple-darwin --bundles app,dmg
windows-build:
name: Windows build (nsis)
# Symmetry with the Linux/macOS build jobs: prove the Windows installer bundles on
# every push to main (the rust matrix only compiles). The core (app-xray.exe +
# wintun.dll) is committed, so no fetch. The signed public release is still cut by
# release.yml on a tag.
if: github.event_name == 'push'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: npm ci
- name: Build the installer (nsis)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --bundles nsis
android-build:
name: Android build (apk)
# Best-effort (continue-on-error): assembles the APK to catch build breaks. The
# native VPN plugin (src-tauri/mobile) is not wired yet, so this stays non-blocking
# until `tauri android init` + the plugin land. Pushes to main only.
if: github.event_name == 'push'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
- name: Install the Android NDK
run: |
sdkmanager "ndk;27.0.12077973"
echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,x86_64-linux-android
- name: Install Linux Tauri dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev
- run: npm ci
- name: Fetch the Android Xray core
run: npm run fetch-core:android
# No prebuilt Android tun2socks exists, so it is built from source here. It is
# what bridges the VpnService tun fd into the core's SOCKS inbound.
- name: Build tun2socks
run: npm run build-tun2socks
- name: Assemble the APK
run: npm run tauri android build -- --apk --target aarch64
# iOS CI is intentionally omitted for now: it needs `tauri ios init` (a Mac), a Packet
# Tunnel extension target, an xcframework core, and a paid Apple Developer account to
# sign. Build it locally with `npm run ios-build` once set up (see src-tauri/mobile).