Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,10 @@ jobs:

- run: npm ci

# The Android core is libXray's prebuilt gomobile AAR (Xray-core in-process); no
# tun2socks and no per-ABI native build - the fd goes straight to Xray's tun inbound.
- name: Fetch the Android Xray core (libXray AAR)
# The same official Xray-core binary the desktop builds use, one per shipped
# ABI, packaged as libxray.so; no tun2socks and no per-ABI native build - the
# tun fd goes straight to Xray's tun inbound.
- name: Fetch the Android Xray core
run: npm run fetch-core:android

- name: Assemble the APK
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,10 @@ jobs:

- run: npm ci

# The Android core is libXray's prebuilt gomobile AAR (Xray-core in-process); no
# tun2socks and no per-ABI native build - the fd goes straight to Xray's tun inbound.
- name: Fetch the Android Xray core (libXray AAR)
# The same official Xray-core binary the desktop builds use, one per shipped
# ABI, packaged as libxray.so; no tun2socks and no per-ABI native build - the
# tun fd goes straight to Xray's tun inbound.
- name: Fetch the Android Xray core
run: npm run fetch-core:android

# Android refuses to install an unsigned APK. When these secrets are absent the
Expand Down
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ src-tauri/.updater-key.pem.pub
# against its .dgst), not committed. The Windows core (app-xray.exe + wintun.dll) stays
# committed for now.
src-tauri/assets/app-xray
# Android runs Xray in-process via libXray's gomobile AAR (fetch-core.mjs --android). It is
# ~90 MB and versioned upstream, so it is fetched into app/libs, not committed.
src-tauri/gen/android/app/libs/libXray.aar
# Android ships the same Xray-core binary as libxray.so, one per ABI (fetch-core.mjs
# --android). Fetched into app/xrayLibs like every other core, not committed.
src-tauri/gen/android/app/xrayLibs/

# Android release signing. The keystore and its passwords must never be committed - CI
# supplies them from secrets instead.
Expand Down
42 changes: 24 additions & 18 deletions docs/CROSS-PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ platforms get built. (For *releasing*, see `RELEASING.md`.)
| **Windows** | `platform/windows.rs` | shipping - NSIS installer, signed, auto-updates |
| **Linux** | `platform/unix.rs` (pkexec) | shipping - `.deb` on the release |
| **macOS** | `platform/unix.rs` (osascript) | supported, but no release artifact yet - the release workflow has no macOS job, because an unsigned, un-notarized `.app` is Gatekeeper-blocked. Build from source until an Apple Developer account is in place. |
| **Android** | `vpn` module + libXray AAR | shipping - `.apk` on the release |
| **Android** | `vpn` module + spawned Xray binary | shipping - `.apk` on the release |
| **iOS** | - | not supported |

## How it works
Expand Down Expand Up @@ -65,33 +65,39 @@ macOS]`).

---

## Android (implemented — in-process libXray)
## Android (implemented — the stock Xray binary, spawned with the tun fd)

Android can't reuse the desktop model (the OS owns the VPN via `VpnService`, and apps
can't freely exec binaries). See `src-tauri/mobile/README.md` for the implemented design;
this is the architecture rationale.

**Why in-process, not the tun2socks split we first planned:** the original plan was a
v2rayNG-style bridge — `VpnService` establishes the tun, a tun2socks lib (loaded in-process
via JNI) forwards the fd into a spawned Xray's SOCKS inbound. It was abandoned once the real
constraint was measured: a spawned Xray can't receive the tun fd at all. Android's
`ProcessBuilder` closes every fd ≥ 3 across `exec` (verified on-device, even with
`FD_CLOEXEC` cleared), so `XRAY_TUN_FD` is meaningless to a child process. That left two
in-process options, and running Xray itself in-process (via libXray) is strictly simpler
than keeping a spawned Xray plus an in-process tun2socks — and lets Xray's own `tun` inbound
do the layer-3 work, so no tun2socks at all.
**Why not the tun2socks split we first planned:** the original plan was a v2rayNG-style
bridge — `VpnService` establishes the tun, a tun2socks lib (loaded in-process via JNI)
forwards the fd into a spawned Xray's SOCKS inbound. Unnecessary: Xray's own `tun` inbound
does the layer-3 work given the fd, which `XRAY_TUN_FD` names.

**Why the spawn is native, not Kotlin:** Java's `ProcessBuilder` closes every fd ≥ 3 across
`exec` (measured on-device, even with `FD_CLOEXEC` cleared), so a core started from Kotlin
would find nothing behind the number. That is a `ProcessBuilder` behaviour, not a kernel one
— a native `fork`/`exec` inherits whatever isn't marked `FD_CLOEXEC`, and `dup()` returns a
copy with that flag cleared. So the spawn lives in Rust, and the app ships the same official
Xray-core binary as every other platform instead of a gomobile binding.

**Architecture (as built):**
- Xray runs in-process through [libXray](https://github.com/XTLS/libXray)'s gomobile AAR
(`fetch-core.mjs --android` → `gen/android/app/libs/libXray.aar`, gitignored). No jniLibs
Xray binary, no tun2socks, no NDK build for the core.
- The core is `Xray-android-<abi>` from the official release, installed as
`gen/android/app/xrayLibs/<abi>/libxray.so` by `fetch-core.mjs --android` (gitignored) and
packaged with `useLegacyPackaging` so it lands in `nativeLibraryDir` — since API 29 the
only directory an app may exec from. No tun2socks, no NDK build for the core.
- `TunnelService` (Kotlin): consent → `Builder` (addr/route `0.0.0.0/0`, DNS, MTU) →
`establish()` → tun fd → injected into the config root `env` as `xray.tun.fd` →
`LibXray.invoke(runXrayFromJson)`. Xray's `tun` inbound reads the fd and proxies out;
its outbound sockets are `protect()`-ed via libXray's `DialerController` callback.
`establish()` → tun fd → `XrayCore.start` → `android_core.rs` dups the fd and execs the
core with `XRAY_TUN_FD` and `XRAY_LOCATION_ASSET` set. Xray's `tun` inbound reads the fd
and proxies out.
- The core's own uplink stays out of the tunnel by uid, not by socket: `protect()` can't
reach another process, so `TunnelService` excludes this package from the VPN and the child
inherits that exclusion.
- `buildMobileConfig` (`config.ts`) emits the `tun` inbound (not a SOCKS inbound) plus a
loopback `metrics` listener; `TunnelService` polls `/debug/vars` for traffic counters,
since there's no binary to run `xray api statsquery` against.
which works across the process boundary because it is loopback.
- Plugin `disruptor-vpn` (`vpn.rs` inline plugin + `VpnPlugin.kt`) exposes
`start/stop/traffic`; `capabilities/mobile.json` grants it. TCP ping stays a socket connect.

Expand Down
88 changes: 43 additions & 45 deletions scripts/fetch-core.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
#!/usr/bin/env node
// Fetches the Xray core, verified against the release's own .dgst checksum.
//
// Desktop cores are the Xray-core CLI binary and land in src-tauri/assets. Android is
// different: a VpnService tun fd can only reach Xray inside the app's own process (a
// spawned binary never inherits it - Android's ProcessBuilder closes it across exec), so
// Android runs Xray IN-PROCESS via libXray's gomobile AAR instead of a libxray.so binary.
// `--android` fetches that AAR into gen/android/app/libs. The Windows core is committed
// already; everything else is fetched (and gitignored) by CI and a fresh clone before a
// build. iOS uses an xcframework built with gomobile, so it is out of scope here.
// Desktop cores are the Xray-core CLI binary and land in src-tauri/assets. Android gets the
// same official binary, one per shipped ABI, installed as `libxray.so` into a jniLibs source
// dir so the APK carries it into nativeLibraryDir - the only place Android lets an app exec a
// file. `--android` does that for both ABIs at once. The Windows core is committed already;
// everything else is fetched (and gitignored) by CI and a fresh clone before a build. iOS
// uses an xcframework built with gomobile, so it is out of scope here.
//
// Usage:
// node scripts/fetch-core.mjs # the host desktop platform
// node scripts/fetch-core.mjs --target linux-64
// node scripts/fetch-core.mjs --android # libXray AAR -> app/libs
// node scripts/fetch-core.mjs --android # both Android ABIs -> app/xrayLibs
// node scripts/fetch-core.mjs --target linux-64 --version v1.8.4
// node scripts/fetch-core.mjs --android --libxray-version v26.7.11
// node scripts/fetch-core.mjs --android --version v26.3.27

import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
Expand All @@ -26,15 +25,16 @@ import { fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ASSETS = path.join(ROOT, 'src-tauri', 'assets');
const LIBS = path.join(ROOT, 'src-tauri', 'gen', 'android', 'app', 'libs');
const XRAY_LIBS = path.join(ROOT, 'src-tauri', 'gen', 'android', 'app', 'xrayLibs');
const RELEASES = 'https://github.com/XTLS/Xray-core/releases';

// libXray ships Xray-core as a prebuilt gomobile AAR. Pinned rather than "latest" so a
// build is reproducible and an upstream change can't silently alter the bundled core;
// bump deliberately (and re-test the tunnel on a device - the tun/fd path is version-
// sensitive). Overridable with --libxray-version.
const LIBXRAY_RELEASES = 'https://github.com/XTLS/libXray/releases';
const LIBXRAY_VERSION = 'v26.7.11';
// The ABIs the APK ships, and the release asset that carries the core for each. Kept in
// step with `abiFilters` in gen/android/app/build.gradle.kts: an ABI with no core here
// would install fine and then fail to start the tunnel.
const ANDROID_ABIS = [
{ abi: 'arm64-v8a', target: 'android-arm64-v8a' },
{ abi: 'x86_64', target: 'android-amd64' }
];

function fail(message)
{
Expand Down Expand Up @@ -158,45 +158,43 @@ async function install(target, version)
}
}

/** Fetches the libXray gomobile AAR and installs it into gen/android/app/libs. Unlike the
* desktop cores, libXray publishes no .dgst, so this pins the version and records the
* sha256 of what it fetched over HTTPS rather than verifying against a published digest. */
async function installAndroidAar(version)
/** Installs the Android core for every shipped ABI into app/xrayLibs, named `libxray.so`.
* The `.so` name is not a library - it is the plain Xray-core executable wearing the only
* extension Android's packager will carry into nativeLibraryDir, which since API 29 is the
* one directory an app is allowed to exec from. */
async function installAndroid(version)
{
const tag = version ?? LIBXRAY_VERSION;
const zipName = 'libxray-android.zip';

console.log(`fetch-core: ${ zipName } (${ tag })`);

const zip = await download(`${ LIBXRAY_RELEASES }/download/${ tag }/${ zipName }`);
console.log(`fetch-core: sha256 ${ createHash('sha256').update(zip).digest('hex') }`);

const work = mkdtempSync(path.join(tmpdir(), 'libxray-'));

try
for (const { abi, target } of ANDROID_ABIS)
{
const zipPath = path.join(work, zipName);
writeFileSync(zipPath, zip);
unzip(zipPath, work);

// The archive nests the AAR one directory down (libxray-android/libXray.aar).
const aar = path.join(work, 'libxray-android', 'libXray.aar');
mkdirSync(LIBS, { recursive: true });
copyFileSync(aar, path.join(LIBS, 'libXray.aar'));
const work = mkdtempSync(path.join(tmpdir(), 'xray-core-'));

console.log('fetch-core: wrote gen/android/.../app/libs/libXray.aar');
}
finally
{
rmSync(work, { recursive: true, force: true });
try
{
const core = await fetchAndVerify(target, version, work);
const dir = path.join(XRAY_LIBS, abi);
mkdirSync(dir, { recursive: true });
const dest = path.join(dir, 'libxray.so');
copyFileSync(core, dest);

if (process.platform !== 'win32')
{
chmodSync(dest, 0o755);
}

console.log(`fetch-core: wrote gen/android/.../app/xrayLibs/${ abi }/libxray.so`);
}
finally
{
rmSync(work, { recursive: true, force: true });
}
}
}

async function main()
{
if (process.argv.includes('--android'))
{
await installAndroidAar(arg('--libxray-version'));
await installAndroid(arg('--version'));
return;
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ reqwest = { version = "0.12", default-features = false, features = [ "socks", "r
# the updater already pulls in, so this adds nothing to the build.
rustls = { version = "0.23", default-features = false, features = [ "ring" ] }

# The Xray core runs as a child process holding the VpnService tun fd, which Kotlin cannot
# hand it (see src/android_core.rs). `jni` matches the version tauri already builds against;
# `libc` is only here for dup/close on the fd.
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
Expand Down
30 changes: 14 additions & 16 deletions src-tauri/gen/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,24 @@ android {
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
// The two ABIs fetch-core installs the core for. The libXray AAR ships libgojni.so
// for all four, so without this the APK would also carry ~100 MB of armeabi-v7a and
// x86 native code no shipped device uses.
// The two ABIs fetch-core installs the core for (see ANDROID_ABIS there). The Rust
// lib builds for four; shipping the other two would mean an APK that installs on a
// device and then has no libxray.so to run.
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}

// The Xray core, fetched per ABI by `npm run fetch-core:android`. Kept out of
// src/main/jniLibs, which the Tauri gradle plugin owns (it symlinks the Rust lib in).
sourceSets.getByName("main").jniLibs.srcDir("xrayLibs")

packaging {
// libxray.so is an executable, not a library: it has to exist as a real file in
// nativeLibraryDir for the core to be exec'd, and that only happens when native
// libs are extracted at install time rather than left compressed in the APK.
jniLibs.useLegacyPackaging = true
}
signingConfigs {
create("release") {
if (keystorePath != null) {
Expand Down Expand Up @@ -92,20 +103,7 @@ rust {
rootDirRel = "../../../"
}

repositories {
// The libXray gomobile AAR is a local artifact fetched by scripts/fetch-core.mjs into
// app/libs (gitignored, not committed), so it is resolved from there rather than a
// remote repository.
flatDir { dirs("libs") }
}

dependencies {
// Xray-core, in-process. On Android a VpnService fd can only reach Xray inside the app's
// own process (a spawned binary never inherits it - ProcessBuilder closes it across
// exec), so the core runs through libXray's gomobile bindings rather than as an exec'd
// libxray.so. TunnelService drives it via LibXray.invoke(...).
implementation(":libXray@aar")

implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
Expand Down
Loading
Loading