fix(build): port build scripts to Python to allow Windows contributors to build subprojects (#83)

* Rewrite build-version and all build-zip bash scripts to python

* Add executable permissions to python build scripts

* Use python build script for kmod in CI

* Fix

* Enhance kmod build script, add/fix docs, CI edits

* Delete remaining build-zip bash scripts

* Delete remaining build-zip bash scripts
This commit is contained in:
Horizon 2026-04-25 19:53:15 +03:00 committed by GitHub
parent 4ad2ba8c2d
commit cf4e72fa01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 490 additions and 206 deletions

View file

@ -77,38 +77,20 @@ jobs:
container:
image: ghcr.io/ylarod/ddk-min:${{ matrix.kmi }}-20260313
env:
KDIR: /opt/ddk/kdir/${{ matrix.kmi }}
KMI: ${{ matrix.kmi }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build kernel module
run: |
CLANG=$(echo /opt/ddk/clang/clang-r*/bin)
make -C $KDIR M=$GITHUB_WORKSPACE/kmod \
ARCH=arm64 LLVM=1 LLVM_IAS=1 \
CC=$CLANG/clang LD=$CLANG/ld.lld \
AR=$CLANG/llvm-ar NM=$CLANG/llvm-nm \
OBJCOPY=$CLANG/llvm-objcopy \
OBJDUMP=$CLANG/llvm-objdump \
STRIP=$CLANG/llvm-strip \
CROSS_COMPILE=aarch64-linux-gnu- \
modules
$CLANG/llvm-strip -d kmod/vpnhide_kmod.ko
- name: Mark workspace safe
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Package KSU module zip
- name: Build and package kernel module
run: |
apt-get update -qq && apt-get install -y -qq zip git >/dev/null
git config --global --add safe.directory "$GITHUB_WORKSPACE"
BUILD_VERSION=$(./scripts/build-version.sh)
echo "Stamping kmod module.prop version=v${BUILD_VERSION} gkiVariant=${{ matrix.kmi }}"
cp kmod/vpnhide_kmod.ko kmod/module/
sed -i "s|^version=.*|version=v${BUILD_VERSION}|" kmod/module/module.prop
echo "gkiVariant=${{ matrix.kmi }}" >> kmod/module/module.prop
echo "updateJson=https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-kmod-${{ matrix.kmi }}.json" >> kmod/module/module.prop
(cd kmod/module && zip -qr "$GITHUB_WORKSPACE/vpnhide-kmod-${{ matrix.kmi }}.zip" .)
cd kmod
python3 ./build-zip.py --kmi $KMI
- name: Upload artifact
uses: actions/upload-artifact@v7
@ -150,7 +132,7 @@ jobs:
UPDATE_JSON_URL: https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-zygisk.json
run: |
cd zygisk
./build-zip.sh
python3 ./build-zip.py
- name: Upload artifact
uses: actions/upload-artifact@v7
@ -225,9 +207,8 @@ jobs:
env:
UPDATE_JSON_URL: https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-ports.json
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq zip >/dev/null
cd portshide
./build-zip.sh
python3 ./build-zip.py
mv vpnhide-ports.zip "$GITHUB_WORKSPACE/vpnhide-ports.zip"
- name: Upload artifact

View file

@ -194,10 +194,32 @@ Rows 1-6, 21, and 24 are the only vectors reachable by regular apps. Everything
## Building from source
- **kmod**: `cd kmod && make && ./build-zip.sh` — see [kmod/BUILDING.md](kmod/BUILDING.md)
- **zygisk**: `cd zygisk && ./build-zip.sh` (Rust + NDK + cargo-ndk)
- **kmod**: `cd kmod && make && ./build-zip.py` — see [kmod/BUILDING.md](kmod/BUILDING.md)
- **zygisk**: `cd zygisk && ./build-zip.py` (Rust + NDK + cargo-ndk)
- **lsposed**: `cd lsposed && ./gradlew assembleDebug` (JDK 17 + Rust + NDK + cargo-ndk)
### Notes for contributors stuck on Windows
If you're on Windows, there are some inconveniences with building some subprojects.
**lsposed**: builds fine in Android Studio.
**portshide**: `cd .\portshide\; python .\build-zip.py` runs fine.
For the next two, you'll (unfortunately) need to install [Docker for Windows](https://docs.docker.com/desktop/setup/install/windows-install/).
**kmod**:
```powershell
$env:KMI="android12-5.10"; docker run --rm -it -v "${PWD}:/workspace" -e KMI=$env:KMI -w /workspace "ghcr.io/ylarod/ddk-min:$($env:KMI)-20260313" bash -c 'cd kmod && python3 ./build-zip.py --kmi $KMI'
```
Be sure to use the same version of `ylarod/ddk-min` image (the date after KMI name) as used in the `ci.yml` workflow file.
**zygisk**:
```powershell
docker run --rm -it -v "${PWD}:/workspace" -v "vpnhide_cargo_cache:/usr/local/cargo/registry" -w /workspace ghcr.io/okhsunrog/vpnhide/ci:latest bash -c 'cd zygisk && python3 ./build-zip.py'
```
The reason why `zygisk` can't be built directly is because source code of dependency `zygisk-api` contains a file named `aux.rs`. Cargo uses `libgit2` for git operations and it contains a guard, which forbids creating files _containing_ reserved Windows words. You'll get an error: `cannot checkout to invalid path 'src/aux.rs'; class=Checkout (20)`. [Someone reports](https://superuser.com/a/1929659), that it bacame possible to create files containing reserved words **with** an extension after some update, but it seems such behavior wasn't modified in `libgit2`.
## Verified against
- [RKNHardering](https://github.com/xtclovver/RKNHardering/) — all detection vectors clean

View file

@ -194,10 +194,32 @@ vpnhide — это не один переключатель, а три разн
## Сборка из исходников
- **kmod**: `cd kmod && make && ./build-zip.sh` — см. [kmod/BUILDING.md](kmod/BUILDING.md)
- **zygisk**: `cd zygisk && ./build-zip.sh` (Rust + NDK + cargo-ndk)
- **kmod**: `cd kmod && make && ./build-zip.py` — см. [kmod/BUILDING.md](kmod/BUILDING.md)
- **zygisk**: `cd zygisk && ./build-zip.py` (Rust + NDK + cargo-ndk)
- **lsposed**: `cd lsposed && ./gradlew assembleDebug` (JDK 17 + Rust + NDK + cargo-ndk)
### Заметки для контрибьюторов, застрявших на Windows
Если вы используете Windows, при сборке некоторых подпроектов возникают определенные неудобства.
**lsposed**: отлично собирается в Android Studio.
**portshide**: `cd .\portshide\; python .\build-zip.py` выполняется без проблем.
Для следующих двух вам (к сожалению) потребуется установить [Docker for Windows](https://docs.docker.com/desktop/setup/install/windows-install/).
**kmod**:
```powershell
$env:KMI="android12-5.10"; docker run --rm -it -v "${PWD}:/workspace" -e KMI=$env:KMI -w /workspace "ghcr.io/ylarod/ddk-min:$($env:KMI)-20260313" bash -c 'cd kmod && python3 ./build-zip.py --kmi $KMI'
```
Обязательно используйте ту же версию образа `ylarod/ddk-min` (дата после названия KMI), которая используется в `ci.yml`.
**zygisk**:
```powershell
docker run --rm -it -v "${PWD}:/workspace" -v "vpnhide_cargo_cache:/usr/local/cargo/registry" -w /workspace ghcr.io/okhsunrog/vpnhide/ci:latest bash -c 'cd zygisk && python3 ./build-zip.py'
```
Причина, по которой `zygisk` нельзя собрать напрямую, заключается в том, что исходный код зависимости `zygisk-api` содержит файл с именем `aux.rs`. Cargo использует `libgit2` для работы с git, в котором есть защита, запрещающая создавать файлы, _содержащие_ зарезервированные слова Windows. Вы получите ошибку: `cannot checkout to invalid path 'src/aux.rs'; class=Checkout (20)`. [Сообщают](https://superuser.com/a/1929659), что после какого-то обновления стало возможным создавать файлы, содержащие зарезервированные слова, **с** расширением, но, похоже, в `libgit2` это поведение не было изменено.
## Проверено на
- [RKNHardering](https://github.com/xtclovver/RKNHardering/) — все векторы обнаружения чисты

View file

@ -56,7 +56,7 @@ keytool -genkey -v -keystore ~/vpnhide.jks \
### zygisk module
```sh
cd zygisk && ./build-zip.sh
cd zygisk && ./build-zip.py
# → zygisk/target/vpnhide-zygisk.zip
```

View file

@ -48,7 +48,7 @@ Update-json **must** be committed *after* the GitHub release is **published** (i
## Build versions
Every packaging step runs `./scripts/build-version.sh` to compute the version string stamped into the artifact:
Every packaging step runs `./scripts/build-version.py` to compute the version string stamped into the artifact:
- **On a release tag `vX.Y.Z`:** `X.Y.Z`
- **N commits after the nearest tag:** `X.Y.Z-N-gSHA` (the git describe format)
@ -61,6 +61,6 @@ This string goes into:
- APK `versionName` (visible in Android Settings → Apps, diagnostic debug zip, `BuildConfig.VERSION_NAME`)
- Inside the zip filenames (only for release tags; dev artifacts in CI keep a stable name)
The committed `module.prop` files are **not** modified — `build-zip.sh` stages a copy, patches the version there, and zips. `lsposed/app/build.gradle.kts` evaluates `build-version.sh` at configure time and sets `versionName` dynamically.
The committed `module.prop` files are **not** modified — `build-zip.py` stages a copy, patches the version there, and zips. `lsposed/app/build.gradle.kts` evaluates `build-version.py` at configure time and sets `versionName` dynamically.
`versionCode` stays at the value baked in by the last `release.py` run (monotonically increasing integer required by Android/Magisk).

View file

@ -15,16 +15,9 @@ KMI=android14-6.1
# Build the kernel module (run from repo root)
docker run --rm -v $(pwd)/kmod:/work \
ghcr.io/ylarod/ddk-min:${KMI}-20260313 sh -c "
CLANG=\$(echo /opt/ddk/clang/clang-r*/bin) && \
make -C /opt/ddk/kdir/${KMI} M=/work \
ARCH=arm64 LLVM=1 LLVM_IAS=1 \
CC=\$CLANG/clang LD=\$CLANG/ld.lld \
AR=\$CLANG/llvm-ar NM=\$CLANG/llvm-nm \
OBJCOPY=\$CLANG/llvm-objcopy \
OBJDUMP=\$CLANG/llvm-objdump \
STRIP=\$CLANG/llvm-strip \
CROSS_COMPILE=aarch64-linux-gnu- \
modules"
CLANG_DIR=\$(echo /opt/ddk/clang/clang-r*/bin) \
KERNEL_SRC=/opt/ddk/kdir/${KMI} \
make"
# Package as KSU module
cp kmod/vpnhide_kmod.ko kmod/module/
@ -43,17 +36,11 @@ KMI=android14-6.1
podman run --rm --userns=keep-id -v "$(pwd)/kmod:/work:Z" \
ghcr.io/ylarod/ddk-min:${KMI}-20260313 sh -c '
CLANG=$(echo /opt/ddk/clang/clang-r*/bin) && \
make -C /opt/ddk/kdir/'${KMI}' M=/work \
ARCH=arm64 LLVM=1 LLVM_IAS=1 \
CC=$CLANG/clang LD=$CLANG/ld.lld \
AR=$CLANG/llvm-ar NM=$CLANG/llvm-nm \
OBJCOPY=$CLANG/llvm-objcopy \
OBJDUMP=$CLANG/llvm-objdump \
STRIP=$CLANG/llvm-strip \
CROSS_COMPILE=aarch64-linux-gnu- \
modules'
CLANG_DIR=\$(echo /opt/ddk/clang/clang-r*/bin) \
KERNEL_SRC=/opt/ddk/kdir/${KMI} \
make'
# Package as KSU module
cp kmod/vpnhide_kmod.ko kmod/module/
(cd kmod/module && zip -qr ../../vpnhide-kmod.zip .)
```
@ -70,7 +57,7 @@ cp .env.example .env
# Edit .env with paths to your kernel source and clang toolchain
direnv allow
make
./build-zip.sh
./build-zip.py
```
See `.env.example` for the required variables. You need a prepared kernel source tree with headers and `Module.symvers`.

View file

@ -29,6 +29,9 @@ MAKE_ARGS := -C $(KERNEL_SRC) M=$(CURDIR) \
all:
$(MAKE) $(MAKE_ARGS) modules
strip: all
$(STRIP) -d $(CURDIR)/vpnhide_kmod.ko
clean:
$(MAKE) $(MAKE_ARGS) clean

View file

@ -34,7 +34,7 @@ CI builds are provided for all 7 GKI generations: `android12-5.10` through `andr
See [BUILDING.md](BUILDING.md) for the full guide (DDK Docker build, kernel source preparation, toolchain setup, `Module.symvers` generation).
```bash
cd kmod && ./build-zip.sh
cd kmod && ./build-zip.py
```
## Install

166
kmod/build-zip.py Executable file
View file

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Build and package the KernelSU/Magisk kernel module zip.
Assembles a module staging directory so the committed module.prop stays at
its release version while the zip carries the actual build version (git describe).
Usage:
python3 build-zip.py --kdir /path/to/kdir --kmi android14-5.15 # explicit args
python3 build-zip.py --kdir /path/to/kdir --kmi android14-5.15 --out custom.zip # custom output
# Or use environment variables: KDIR and KMI (CLI args override env vars)
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from build_lib import get_build_version, make_zip # type: ignore[import-not-found]
# Module file names
KMOD_C = "vpnhide_kmod.c"
KMOD_KO = "vpnhide_kmod.ko"
def main() -> int:
parser = argparse.ArgumentParser(description="Build and package the kernel module zip.")
parser.add_argument(
"--kdir",
type=str,
help="Kernel source directory (overrides KDIR or KERNEL_SRC env var)",
)
parser.add_argument(
"--kmi",
type=str,
help="Kernel module interface variant (e.g., android14-5.15) for module.prop (overrides KMI env var)",
)
parser.add_argument(
"--out",
type=str,
help="Output zip filename (default: vpnhide-kmod-<kmi>.zip)",
)
parser.add_argument(
"--clang-dir",
type=str,
help="Clang binaries directory (overrides CLANG_DIR env var or auto-detects)",
)
args = parser.parse_args()
kmod_dir = Path(__file__).resolve().parent
os.chdir(kmod_dir)
# Resolve kdir: CLI arg > KDIR env var > KERNEL_SRC env var
if args.kdir:
kdir = args.kdir
kdir_src = "--kdir CLI argument"
else:
kdir = os.environ.get("KDIR")
kdir_src = "KDIR env var"
if not kdir:
kdir = os.environ.get("KERNEL_SRC")
kdir_src = "KERNEL_SRC env var"
if not kdir:
print("Error: --kdir argument, KDIR, or KERNEL_SRC env var is required", file=sys.stderr)
return 1
print(f"Using kdir from {kdir_src}: {kdir}")
# Resolve kmi: CLI arg > KMI env var
if args.kmi:
kmi = args.kmi
kmi_src = "--kmi CLI argument"
else:
kmi = os.environ.get("KMI")
kmi_src = "KMI env var"
if not kmi:
print("Error: --kmi argument or KMI env var is required", file=sys.stderr)
return 1
print(f"Using kmi from {kmi_src}: {kmi}")
# Set up environment for make
os.environ["KERNEL_SRC"] = kdir
# Resolve clang_dir: CLI arg > CLANG_DIR env var > auto-detect
clang_dir = None
clang_dir_src = None
if args.clang_dir:
clang_dir = args.clang_dir
clang_dir_src = "--clang-dir CLI argument"
else:
clang_dir = os.environ.get("CLANG_DIR")
if clang_dir:
clang_dir_src = "CLANG_DIR env var"
else:
# Auto-detect: In CI, clang is at /opt/ddk/clang/clang-r*/bin
clang_base = Path("/opt/ddk/clang")
if clang_base.exists():
clang_dirs = sorted(d for d in clang_base.iterdir() if d.is_dir() and d.name.startswith("clang-"))
if clang_dirs:
clang_dir = str(clang_dirs[-1] / "bin")
clang_dir_src = "auto-detected from /opt/ddk/clang"
if clang_dir:
os.environ["CLANG_DIR"] = clang_dir
print(f"Using clang-dir from {clang_dir_src}: {clang_dir}")
else:
print("Warning: clang-dir not set, using system PATH", file=sys.stderr)
# Build the kernel module (env vars loaded by direnv from .env)
kmod_c = kmod_dir / KMOD_C
kmod_ko = kmod_dir / KMOD_KO
if not kmod_ko.exists() or kmod_c.stat().st_mtime > kmod_ko.stat().st_mtime:
print("Building kernel module...")
subprocess.run(["make", "strip"], check=True)
# Assemble the module staging directory so the committed module.prop
# stays at its release version while the zip carries the actual build
# version (git describe).
staging = kmod_dir / "module-staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(kmod_dir / "module", staging)
shutil.copy(kmod_dir / KMOD_KO, staging / KMOD_KO)
# Get build version
build_version = get_build_version(kmod_dir.parent)
# Stamp version into module.prop
module_prop = staging / "module.prop"
content = module_prop.read_text(encoding="utf-8")
content = re.sub(r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE)
# Add gkiVariant and updateJson
content = re.sub(r"^gkiVariant=.*", f"gkiVariant={kmi}", content, flags=re.MULTILINE)
if not re.search(r"^gkiVariant=", content, flags=re.MULTILINE):
content = content.rstrip() + f"\ngkiVariant={kmi}\n"
update_json_url = f"https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-kmod-{kmi}.json"
content = re.sub(r"^updateJson=.*", f"updateJson={update_json_url}", content, flags=re.MULTILINE)
if not re.search(r"^updateJson=", content, flags=re.MULTILINE):
content = content.rstrip() + f"\nupdateJson={update_json_url}\n"
module_prop.write_text(content, encoding="utf-8")
print(f"Stamped module.prop version=v{build_version} gkiVariant={kmi}")
# Create zip in parent directory (workspace root for CI)
out_zip = kmod_dir.parent / (args.out if args.out else f"vpnhide-kmod-{kmi}.zip")
if out_zip.exists():
out_zip.unlink()
make_zip(staging, out_zip)
shutil.rmtree(staging)
print()
print(f"Built: {out_zip.name}")
size_kb = out_zip.stat().st_size / 1024
print(f" {out_zip} ({size_kb:.1f} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# Build the kernel module (env vars loaded by direnv from .env)
if [ ! -f vpnhide_kmod.ko ] || [ vpnhide_kmod.c -nt vpnhide_kmod.ko ]; then
echo "Building kernel module..."
make
fi
# Assemble the module staging directory so the committed module.prop
# stays at its release version while the zip carries the actual build
# version (git describe).
STAGING="module-staging"
rm -rf "$STAGING"
cp -a module "$STAGING"
cp vpnhide_kmod.ko "$STAGING/vpnhide_kmod.ko"
BUILD_VERSION="$(../scripts/build-version.sh)"
sed -i "s|^version=.*|version=v${BUILD_VERSION}|" "$STAGING/module.prop"
echo "Stamped module.prop version=v${BUILD_VERSION}"
OUT="vpnhide-kmod.zip"
rm -f "$OUT"
(cd "$STAGING" && zip -qr "../$OUT" .)
rm -rf "$STAGING"
echo
echo "Built: $OUT"
ls -lh "$OUT"

View file

@ -11,16 +11,22 @@ android {
namespace = "dev.okhsunrog.vpnhide"
compileSdk = 35
// Effective build version from ../scripts/build-version.sh:
// Effective build version from ../scripts/build-version.py:
// release tag -> "0.6.2"
// dev build -> "0.6.1-5-gabc1234" (+"-dirty" if uncommitted)
// no git -> VERSION file
// Python instead of bash so Windows contributors can build without WSL.
// Script is stdlib-only — no `uv` / pip install needed. `python` on
// Windows, `python3` elsewhere: Ubuntu 22.04+ ships only the latter,
// Windows python.org / Store installer ships only the former.
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
val pythonExe = if (isWindows) "python" else "python3"
val buildVersion: String =
providers
.exec {
commandLine(
"bash",
rootProject.projectDir.parentFile.resolve("scripts/build-version.sh").absolutePath,
pythonExe,
rootProject.projectDir.parentFile.resolve("scripts/build-version.py").absolutePath,
)
}.standardOutput.asText
.get()

View file

@ -92,7 +92,7 @@ class BaseVersionTest {
@Test
fun `dirty suffix alone is stripped`() {
// build-version.sh emits `X.Y.Z-dirty` when HEAD is on a tag but
// build-version.py emits `X.Y.Z-dirty` when HEAD is on a tag but
// the working tree has uncommitted changes.
assertEquals("0.6.2", baseVersion("0.6.2-dirty"))
assertEquals("0.6.2", baseVersion("v0.6.2-dirty"))

65
portshide/build-zip.py Executable file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Build and package the ports-hiding Magisk/KernelSU module zip.
Assembles a module staging directory so the committed module.prop stays at
its release version while the zip carries the actual build version (git describe).
"""
from __future__ import annotations
import os
import re
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from build_lib import get_build_version, make_zip # type: ignore[import-not-found]
def main() -> int:
script_dir = Path(__file__).resolve().parent
os.chdir(script_dir)
# Assemble the module staging directory so the committed module.prop
# stays at its release version while the zip carries the actual build
# version (git describe).
staging = script_dir / "module-staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(script_dir / "module", staging)
# Get build version
build_version = get_build_version(script_dir.parent)
# Stamp version into module.prop
module_prop = staging / "module.prop"
content = module_prop.read_text(encoding="utf-8")
content = re.sub(r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE)
module_prop.write_text(content, encoding="utf-8")
print(f"Stamped module.prop version=v{build_version}")
# CI sets UPDATE_JSON_URL so Magisk/KSU knows where to check for updates;
# local dev builds leave it unset and ship without updateJson.
update_json_url = os.environ.get("UPDATE_JSON_URL")
if update_json_url:
with open(module_prop, "a", encoding="utf-8") as f:
f.write(f"updateJson={update_json_url}\n")
# Create zip
out_zip = script_dir / "vpnhide-ports.zip"
if out_zip.exists():
out_zip.unlink()
make_zip(staging, out_zip)
shutil.rmtree(staging)
print()
print(f"Built: {out_zip.name}")
size_kb = out_zip.stat().st_size / 1024
print(f" {out_zip} ({size_kb:.1f} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# Assemble the module staging directory so the committed module.prop
# stays at its release version while the zip carries the actual build
# version (git describe).
STAGING="module-staging"
rm -rf "$STAGING"
cp -a module "$STAGING"
BUILD_VERSION="$(../scripts/build-version.sh)"
sed -i "s|^version=.*|version=v${BUILD_VERSION}|" "$STAGING/module.prop"
echo "Stamped module.prop version=v${BUILD_VERSION}"
# CI sets UPDATE_JSON_URL so Magisk/KSU knows where to check for updates;
# local dev builds leave it unset and ship without updateJson.
if [ -n "${UPDATE_JSON_URL:-}" ]; then
echo "updateJson=${UPDATE_JSON_URL}" >> "$STAGING/module.prop"
fi
OUT="vpnhide-ports.zip"
rm -f "$OUT"
(cd "$STAGING" && zip -qr "../$OUT" .)
rm -rf "$STAGING"
echo
echo "Built: $OUT"
ls -lh "$OUT"

24
scripts/build-version.py Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Print the effective build version for vpnhide artifacts.
Used by every packaging step (module.prop, APK versionName, CI
artifact names) so dev builds are unambiguously identifiable at a
glance. Called from `app/build.gradle.kts` on every Gradle build, so
stays on stdlib only Gradle shouldn't need `uv` / external deps to
assemble the APK.
"""
from __future__ import annotations
import sys
from build_lib import get_build_version
def main() -> int:
print(get_build_version())
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,20 +0,0 @@
#!/usr/bin/env bash
# Print the effective build version for vpnhide artifacts.
#
# - HEAD on a tag vX.Y.Z -> "X.Y.Z" (release build)
# - N commits after tag vX.Y.Z -> "X.Y.Z-N-gSHA" (dev build)
# - working tree dirty -> additional "-dirty" suffix
# - no git / no matching tag -> falls back to VERSION file
#
# Used by every packaging step (module.prop, APK versionName, CI artifact
# names) so dev builds are unambiguously identifiable at a glance.
set -euo pipefail
cd "$(dirname "$0")/.."
if git rev-parse --git-dir >/dev/null 2>&1 \
&& raw=$(git describe --tags --match 'v*' --dirty 2>/dev/null); then
echo "${raw#v}"
else
cat VERSION
fi

50
scripts/build_lib.py Normal file
View file

@ -0,0 +1,50 @@
"""Shared helpers for build scripts.
Used by kmod/build-zip.py, portshide/build-zip.py, and zygisk/build-zip.py.
"""
from __future__ import annotations
import os
import zipfile
from pathlib import Path
def get_python_exe() -> str:
"""Return 'python' on Windows, 'python3' elsewhere."""
return "python" if os.name == "nt" else "python3"
def make_zip(source_dir: Path, output_zip: Path) -> None:
"""Create a zip archive from source_dir contents."""
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in source_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(source_dir)
zf.write(file_path, arcname)
def get_build_version(repo_root: Path | None = None) -> str:
"""Get the effective build version for vpnhide artifacts.
- HEAD on a tag vX.Y.Z -> "X.Y.Z" (release build)
- N commits after tag vX.Y.Z -> "X.Y.Z-N-gSHA" (dev build)
- working tree dirty -> additional "-dirty" suffix
- no git / no matching tag -> falls back to VERSION file
"""
import subprocess
if repo_root is None:
repo_root = Path(__file__).resolve().parent.parent
result = subprocess.run(
["git", "describe", "--tags", "--match", "v*", "--dirty"],
cwd=repo_root,
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().removeprefix("v")
version_file = repo_root / "VERSION"
return version_file.read_text(encoding="utf-8").strip()

View file

@ -74,7 +74,7 @@ Requirements:
Build and package:
```bash
./build-zip.sh
./build-zip.py
# Output: target/vpnhide-zygisk.zip (~180 KB)
```
@ -131,7 +131,7 @@ VPN interface prefixes: `tun`, `ppp`, `tap`, `wg`, `ipsec`, `xfrm`, `utun`, `l2t
- `build.rs` -- drives CMake on the shadowhook submodule
- `third_party/android-inline-hook/` -- submodule (our shadowhook fork)
- `module/` -- KernelSU/Magisk module metadata
- `build-zip.sh` -- cross-compile + package script
- `build-zip.py` -- cross-compile + package script
## License

102
zygisk/build-zip.py Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Build the native library for aarch64 Android and package it into an
installable KernelSU/Magisk module zip.
Requirements:
- rustup target aarch64-linux-android (already installed)
- cargo-ndk
- Android NDK at $ANDROID_NDK_HOME or auto-detected from $HOME/Android/Sdk/ndk/*
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from build_lib import get_build_version, make_zip # type: ignore[import-not-found]
def main() -> int:
script_dir = Path(__file__).resolve().parent
os.chdir(script_dir)
# Auto-detect NDK if ANDROID_NDK_HOME isn't set
android_ndk_home = os.environ.get("ANDROID_NDK_HOME")
if not android_ndk_home:
ndk_base = Path.home() / "Android" / "Sdk" / "ndk"
if ndk_base.exists():
ndk_versions = sorted(
d.name for d in ndk_base.iterdir() if d.is_dir()
)
if ndk_versions:
android_ndk_home = str(ndk_base / ndk_versions[-1])
if not android_ndk_home or not Path(android_ndk_home).is_dir():
print(
"error: ANDROID_NDK_HOME not set and no NDK found under ~/Android/Sdk/ndk",
file=sys.stderr,
)
return 1
print(f"Using NDK: {android_ndk_home}")
os.environ["ANDROID_NDK_HOME"] = android_ndk_home
# Build the cdylib for arm64-v8a
subprocess.run(
["cargo", "ndk", "-t", "arm64-v8a", "build", "--release"],
check=True,
)
so_src = script_dir / "target" / "aarch64-linux-android" / "release" / "libvpnhide_zygisk.so"
if not so_src.exists():
print(f"error: expected {so_src} after cargo ndk build, not found", file=sys.stderr)
return 1
# Assemble the module staging directory
staging = script_dir / "target" / "module-staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(script_dir / "module", staging)
(staging / "zygisk").mkdir(parents=True, exist_ok=True)
shutil.copy(so_src, staging / "zygisk" / "arm64-v8a.so")
# Get build version
build_version = get_build_version(script_dir.parent)
# Stamp the effective build version into the staging module.prop without
# touching the committed file. On a release tag this matches VERSION; on
# any other commit the git suffix makes dev builds identifiable.
module_prop = staging / "module.prop"
content = module_prop.read_text(encoding="utf-8")
content = re.sub(r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE)
module_prop.write_text(content, encoding="utf-8")
print(f"Stamped module.prop version=v{build_version}")
# CI sets UPDATE_JSON_URL so Magisk/KSU knows where to check for updates;
# local dev builds leave it unset and ship without updateJson.
update_json_url = os.environ.get("UPDATE_JSON_URL")
if update_json_url:
with open(module_prop, "a", encoding="utf-8") as f:
f.write(f"updateJson={update_json_url}\n")
# Zip it
out_zip = script_dir / "target" / "vpnhide-zygisk.zip"
if out_zip.exists():
out_zip.unlink()
make_zip(staging, out_zip)
print()
print(f"Built: {out_zip.name}")
size_kb = out_zip.stat().st_size / 1024
print(f" {out_zip} ({size_kb:.1f} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,63 +0,0 @@
#!/usr/bin/env bash
# Build the native library for aarch64 Android and package it into an
# installable KernelSU/Magisk module zip.
#
# Requirements:
# - rustup target aarch64-linux-android (already installed)
# - cargo-ndk
# - Android NDK at $ANDROID_NDK_HOME or auto-detected from $HOME/Android/Sdk/ndk/*
# - zip
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_DIR"
# Auto-detect NDK if ANDROID_NDK_HOME isn't set
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
ANDROID_NDK_HOME="$(find "$HOME/Android/Sdk/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1)"
fi
if [ -z "${ANDROID_NDK_HOME:-}" ] || [ ! -d "$ANDROID_NDK_HOME" ]; then
echo "error: ANDROID_NDK_HOME not set and no NDK found under ~/Android/Sdk/ndk" >&2
exit 1
fi
echo "Using NDK: $ANDROID_NDK_HOME"
export ANDROID_NDK_HOME
# Build the cdylib for arm64-v8a
cargo ndk -t arm64-v8a build --release
SO_SRC="target/aarch64-linux-android/release/libvpnhide_zygisk.so"
if [ ! -f "$SO_SRC" ]; then
echo "error: expected $SO_SRC after cargo ndk build, not found" >&2
exit 1
fi
# Assemble the module staging directory
STAGING="target/module-staging"
rm -rf "$STAGING"
cp -a module "$STAGING"
mkdir -p "$STAGING/zygisk"
cp "$SO_SRC" "$STAGING/zygisk/arm64-v8a.so"
# Stamp the effective build version into the staging module.prop without
# touching the committed file. On a release tag this matches VERSION; on
# any other commit the git suffix makes dev builds identifiable.
BUILD_VERSION="$(../scripts/build-version.sh)"
sed -i "s|^version=.*|version=v${BUILD_VERSION}|" "$STAGING/module.prop"
echo "Stamped module.prop version=v${BUILD_VERSION}"
# CI sets UPDATE_JSON_URL so Magisk/KSU knows where to check for updates;
# local dev builds leave it unset and ship without updateJson.
if [ -n "${UPDATE_JSON_URL:-}" ]; then
echo "updateJson=${UPDATE_JSON_URL}" >> "$STAGING/module.prop"
fi
# Zip it
OUT_ZIP="target/vpnhide-zygisk.zip"
rm -f "$OUT_ZIP"
(cd "$STAGING" && zip -qr "../../$OUT_ZIP" .)
echo
echo "Built: $OUT_ZIP"
ls -lh "$OUT_ZIP"