From d6dd27b31de4c868a91d4c9b75dace93df304d5f Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 06:54:12 +0000 Subject: [PATCH 01/62] ci: add Android CI workflow to build APK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit native job 编译 libcore.aar(Go/gomobile)与 hev-tun .so(NDK 25.0.8775105),build job 下载 geo 资源并用 Gradle 打包,支持 oss/fdroid/play/preview 与 release/debug 切换。 --- .github/workflows/build-apk.yml | 422 ++++++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 .github/workflows/build-apk.yml diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000000..133114bc1a --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,422 @@ +name: Android CI + +# 编译 NekoBox APK 的完整流水线: +# native job -> 生成 libcore.aar(Go/gomobile)与 hev-tun 的 .so(NDK) +# build job -> 下载 native 产物 + 下载 geo 资源 + Gradle 打包 APK +on: + push: + branches: + - main + paths: + - 'app/**' + - 'buildSrc/**' + - 'buildScript/**' + - 'libcore/**' + - 'gradle/**' + - 'build.gradle.kts' + - 'settings.gradle.kts' + - 'gradle.properties' + - 'gradlew' + - 'nb4a.properties' + - '.github/workflows/build-apk.yml' + workflow_dispatch: + inputs: + flavor: + description: '产品风味' + type: choice + options: + - oss + - fdroid + - play + - preview + default: oss + build_type: + description: '构建类型' + type: choice + options: + - release + - debug + default: release + +permissions: + contents: read + +concurrency: + group: android-ci-${{ github.ref }} + cancel-in-progress: true + +env: + ANDROID_SDK_ROOT: /usr/local/lib/android/sdk + ANDROID_HOME: /usr/local/lib/android/sdk + SDK_PLATFORM: android-35 # buildSrc/Helpers.kt: compileSdk = 35 + BUILD_TOOLS: 35.0.1 # buildSrc/Helpers.kt: buildToolsVersion = 35.0.1 + NDK_VERSION: 25.0.8775105 # buildScript/init/env_ndk.sh 首选版本 + GRADLE_ARGS: --no-daemon --stacktrace + FLAVOR: ${{ inputs.flavor || 'oss' }} + BUILD_TYPE: ${{ inputs.build_type || 'release' }} + +jobs: + # ---------------------------------------------------------------- native + native: + name: Native libs (libcore + hevtun) + runs-on: ubuntu-latest + timeout-minutes: 90 + if: ${{ !startsWith(github.event.head_commit.message, '[skip ci]') }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Setup Java JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: Restore Android SDK cache + id: sdk-cache + uses: actions/cache/restore@v4 + with: + path: | + /usr/local/lib/android/sdk/platforms/android-35 + /usr/local/lib/android/sdk/build-tools/35.0.1 + key: android-sdk-${{ runner.os }}-35-35.0.1 + restore-keys: | + android-sdk-${{ runner.os }}- + + - name: Restore Android NDK cache + id: ndk-cache + uses: actions/cache/restore@v4 + with: + path: /usr/local/lib/android/sdk/ndk/25.0.8775105 + key: android-ndk-${{ runner.os }}-25.0.8775105 + + - name: Install missing Android SDK / NDK components + if: steps.sdk-cache.outputs.cache-hit != 'true' || steps.ndk-cache.outputs.cache-hit != 'true' + run: | + SDK="$ANDROID_SDK_ROOT" + SDKMGR="$SDK/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$SDKMGR" ]; then + echo "::error::sdkmanager not found at $SDKMGR" + exit 1 + fi + yes | "$SDKMGR" --licenses > /dev/null 2>&1 || true + if [ ! -d "$SDK/platforms/$SDK_PLATFORM" ]; then + yes | "$SDKMGR" "platforms;$SDK_PLATFORM" + fi + if [ ! -d "$SDK/build-tools/$BUILD_TOOLS" ]; then + yes | "$SDKMGR" "build-tools;$BUILD_TOOLS" + fi + if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then + yes | "$SDKMGR" "ndk;$NDK_VERSION" || true + fi + # 兜底:若该 NDK 版本已从仓库移除,退到 runner 上最新的 NDK + if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then + ALT=$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1) + if [ -z "$ALT" ]; then + echo "::error::既没有 $NDK_VERSION,runner 上也没有任何 NDK" + exit 1 + fi + ALT="${ALT%/}" + echo "::warning::$NDK_VERSION 不可用,回退到 $ALT" + echo "ANDROID_NDK_HOME=$ALT" >> "$GITHUB_ENV" + fi + echo "platforms: $(ls "$SDK/platforms" | tr '\n' ' ')" + echo "build-tools: $(ls "$SDK/build-tools" | tr '\n' ' ')" + echo "ndk: $(ls "$SDK/ndk" | tr '\n' ' ')" + + - name: Compute native cache keys + run: | + find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status + git ls-files libcore | xargs cat | sha1sum > libcore_status + git submodule status hev-socks5-tunnel > hev_status + sha1sum buildScript/compile-hevtun.sh >> hev_status + echo "libcore deps: $(cat golang_status libcore_status | tr -d '\n')" + echo "hevtun deps: $(cat hev_status | tr -d '\n')" + + - name: Restore libcore cache + id: libcore-cache + uses: actions/cache/restore@v4 + with: + path: app/libs/libcore.aar + key: libcore-${{ runner.os }}-${{ hashFiles('golang_status', 'libcore_status') }} + restore-keys: | + libcore-${{ runner.os }}- + + - name: Setup Golang + if: steps.libcore-cache.outputs.cache-hit != 'true' + uses: actions/setup-go@v6 + with: + go-version: '^1.25' + cache: false + + - name: Build libcore (Go + gomobile) + if: steps.libcore-cache.outputs.cache-hit != 'true' + run: | + # libcore/init.sh 用裸命令调用 gomobile-matsuri,当前 step 的 PATH 必须同步导出 + export PATH="$(go env GOPATH)/bin:$PATH" + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + echo "NDK: ${ANDROID_NDK_HOME:-$ANDROID_SDK_ROOT/ndk/$NDK_VERSION}" + ./run lib core + ls -lh app/libs/libcore.aar + + - name: Restore hevtun cache + id: hevtun-cache + uses: actions/cache/restore@v4 + with: + path: app/src/main/jniLibs + key: hevtun-${{ runner.os }}-${{ hashFiles('hev_status') }} + restore-keys: | + hevtun-${{ runner.os }}- + + - name: Build hevtun (NDK, 4 ABIs) + if: steps.hevtun-cache.outputs.cache-hit != 'true' + run: | + bash buildScript/compile-hevtun.sh + find app/src/main/jniLibs -name '*.so' -exec ls -lh {} \; + + - name: Save libcore cache + if: steps.libcore-cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: app/libs/libcore.aar + key: libcore-${{ runner.os }}-${{ hashFiles('golang_status', 'libcore_status') }} + + - name: Save hevtun cache + if: steps.hevtun-cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: app/src/main/jniLibs + key: hevtun-${{ runner.os }}-${{ hashFiles('hev_status') }} + + - name: Save Android SDK cache + if: steps.sdk-cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: | + /usr/local/lib/android/sdk/platforms/android-35 + /usr/local/lib/android/sdk/build-tools/35.0.1 + key: android-sdk-${{ runner.os }}-35-35.0.1 + + - name: Save Android NDK cache + if: steps.ndk-cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: /usr/local/lib/android/sdk/ndk/25.0.8775105 + key: android-ndk-${{ runner.os }}-25.0.8775105 + + # 交给 build job,避免依赖缓存 key 的二次命中 + - name: Upload libcore aar + uses: actions/upload-artifact@v7 + with: + name: libcore-aar + path: app/libs/libcore.aar + retention-days: 1 + if-no-files-found: error + + - name: Upload hevtun libs + uses: actions/upload-artifact@v7 + with: + name: hevtun-libs + path: app/src/main/jniLibs + retention-days: 1 + if-no-files-found: error + + # ----------------------------------------------------------------- build + build: + name: Build ${{ inputs.flavor || 'oss' }} APK + runs-on: ubuntu-latest + needs: + - native + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Java JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: Restore Android SDK cache + id: sdk-cache + uses: actions/cache/restore@v4 + with: + path: | + /usr/local/lib/android/sdk/platforms/android-35 + /usr/local/lib/android/sdk/build-tools/35.0.1 + key: android-sdk-${{ runner.os }}-35-35.0.1 + restore-keys: | + android-sdk-${{ runner.os }}- + + - name: Restore Android NDK cache + id: ndk-cache + uses: actions/cache/restore@v4 + with: + path: /usr/local/lib/android/sdk/ndk/25.0.8775105 + key: android-ndk-${{ runner.os }}-25.0.8775105 + + - name: Install missing Android SDK / NDK components + if: steps.sdk-cache.outputs.cache-hit != 'true' || steps.ndk-cache.outputs.cache-hit != 'true' + run: | + SDK="$ANDROID_SDK_ROOT" + SDKMGR="$SDK/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$SDKMGR" ]; then + echo "::error::sdkmanager not found at $SDKMGR" + exit 1 + fi + yes | "$SDKMGR" --licenses > /dev/null 2>&1 || true + [ -d "$SDK/platforms/$SDK_PLATFORM" ] || yes | "$SDKMGR" "platforms;$SDK_PLATFORM" + [ -d "$SDK/build-tools/$BUILD_TOOLS" ] || yes | "$SDKMGR" "build-tools;$BUILD_TOOLS" + if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then + yes | "$SDKMGR" "ndk;$NDK_VERSION" || true + fi + if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then + ALT=$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1) + echo "::warning::$NDK_VERSION 不可用,回退到 ${ALT:-无}" + [ -z "$ALT" ] || echo "ANDROID_NDK_HOME=${ALT%/}" >> "$GITHUB_ENV" + fi + + - name: Download libcore aar + uses: actions/download-artifact@v7 + with: + name: libcore-aar + path: app/libs + + - name: Download hevtun libs + uses: actions/download-artifact@v7 + with: + name: hevtun-libs + path: app/src/main/jniLibs + + - name: Restore Gradle cache + id: gradle-cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Prepare local.properties and signing + id: signing + env: + KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} + ALIAS_NAME: ${{ secrets.ALIAS_NAME }} + ALIAS_PASS: ${{ secrets.ALIAS_PASS }} + run: | + echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties + echo "ndk.dir=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> local.properties + # buildSrc/Helpers.kt 读取 release.keystore(已在仓库内),密码来自 env 或 local.properties + if [ -n "$KEYSTORE_PASS" ] && [ -n "$ALIAS_NAME" ] && [ -n "$ALIAS_PASS" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "release 签名已就绪(release.keystore)" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning::KEYSTORE_PASS / ALIAS_NAME / ALIAS_PASS 未配置,release 将自动回退为 debug 签名" + fi + + - name: Init assets (geoip / geosite) + run: | + ok=0 + for i in 1 2 3; do + if ./run init action gradle; then ok=1; break; fi + echo "::warning::assets 下载失败(第 $i 次),重试中…" + sleep 5 + done + if [ "$ok" != "1" ]; then + echo "::error::geoip/geosite 资源初始化失败" + exit 1 + fi + ls -lh app/src/main/assets/sing-box/ + + - name: Build APK + id: build + env: + BUILD_PLUGIN: none + KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} + ALIAS_NAME: ${{ secrets.ALIAS_NAME }} + ALIAS_PASS: ${{ secrets.ALIAS_PASS }} + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + run: | + set -uo pipefail + cap() { echo "${1^}"; } + + BT="$BUILD_TYPE" + if [ "$BT" = "release" ] && [ -z "${KEYSTORE_PASS:-}" ]; then + # 空字符串会让 Helpers.kt 创建密码为空的 signingConfig,必须 unset + unset KEYSTORE_PASS ALIAS_NAME ALIAS_PASS + BT="debug" + echo "::warning::未配置签名密钥,已自动改为 debug 构建(APK 用 debug key 签名,可直接安装)" + fi + TASK="assemble$(cap "$FLAVOR")$(cap "$BT")" + echo "TASK=$TASK" >> "$GITHUB_ENV" + echo "EFFECTIVE_TYPE=$BT" >> "$GITHUB_ENV" + echo "==> ./gradlew app:$TASK $GRADLE_ARGS" + + set +e + ./gradlew "app:$TASK" $GRADLE_ARGS 2>&1 | tee gradle.log + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + # 把编译错误转成 annotation,便于在 Checks 页直接看到 + grep -E "^e: |error:|FAILURE:|What went wrong|Caused by|> Task .* FAILED" gradle.log | head -40 | while IFS= read -r line; do + echo "::error::$line" + done + exit "$status" + fi + + - name: Collect APKs + id: collect + run: | + OUT="app/build/outputs/apk/${FLAVOR}/${EFFECTIVE_TYPE}" + if [ ! -d "$OUT" ]; then + echo "::error::未找到 APK 输出目录 $OUT" + ls -R app/build/outputs/apk || true + exit 1 + fi + COUNT=$(find "$OUT" -name '*.apk' | wc -l) + if [ "$COUNT" -eq 0 ]; then + echo "::error::$OUT 下没有 APK" + exit 1 + fi + echo "dir=$OUT" >> "$GITHUB_OUTPUT" + echo "=== 产物($COUNT 个)===" + ls -lh "$OUT" + { + echo "### APK 产物:$FLAVOR / $EFFECTIVE_TYPE" + echo "" + echo "| 文件 | 大小 |" + echo "| --- | --- |" + find "$OUT" -name '*.apk' -printf '| %f | %k KB |\n' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Verify APK signature + if: steps.signing.outputs.enabled == 'true' + run: | + APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | tail -1) + echo "使用 $APKSIGNER" + for apk in ${{ steps.collect.outputs.dir }}/*.apk; do + echo "== $apk" + "$APKSIGNER" verify --print-certs "$apk" + done + + - name: Upload APK + uses: actions/upload-artifact@v7 + with: + name: NekoBox-${{ env.FLAVOR }}-${{ env.EFFECTIVE_TYPE }} + path: ${{ steps.collect.outputs.dir }}/*.apk + retention-days: 7 + if-no-files-found: error + + - name: Save Gradle cache + if: steps.gradle-cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/wrapper/gradle-wrapper.properties') }} From 68acc909f0ee09edb62adbf9b62d3f7b62253c04 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:27:29 +0000 Subject: [PATCH 02/62] ci: restrict build to a single ABI, default to debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-apk.yml 增加 abi 输入(默认 arm64-v8a),用 android.injected.build.abi 只出目标 ABI 的 APK,只上传匹配的那一个 - libcore/build.sh 支持 GOMOBILE_TARGET 限定 gomobile 编译 ABI - compile-hevtun.sh 支持 HEV_ABIS 覆盖,CI 只编译目标 ABI --- .github/workflows/build-apk.yml | 84 +++++++++++++++++++++++++-------- buildScript/compile-hevtun.sh | 2 +- libcore/build.sh | 7 ++- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 133114bc1a..9b7a63d97c 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -34,9 +34,19 @@ on: description: '构建类型' type: choice options: - - release - debug - default: release + - release + default: debug + abi: + description: '目标 ABI' + type: choice + options: + - arm64-v8a + - armeabi-v7a + - x86 + - x86_64 + - all + default: arm64-v8a permissions: contents: read @@ -53,7 +63,8 @@ env: NDK_VERSION: 25.0.8775105 # buildScript/init/env_ndk.sh 首选版本 GRADLE_ARGS: --no-daemon --stacktrace FLAVOR: ${{ inputs.flavor || 'oss' }} - BUILD_TYPE: ${{ inputs.build_type || 'release' }} + BUILD_TYPE: ${{ inputs.build_type || 'debug' }} + ABI: ${{ inputs.abi || 'arm64-v8a' }} jobs: # ---------------------------------------------------------------- native @@ -126,6 +137,18 @@ jobs: echo "build-tools: $(ls "$SDK/build-tools" | tr '\n' ' ')" echo "ndk: $(ls "$SDK/ndk" | tr '\n' ' ')" + - name: Resolve ABI + run: | + case "$ABI" in + arm64-v8a) echo "GOMOBILE_TARGET=android/arm64" >> "$GITHUB_ENV"; echo "HEV_ABIS=arm64-v8a" >> "$GITHUB_ENV" ;; + armeabi-v7a) echo "GOMOBILE_TARGET=android/arm" >> "$GITHUB_ENV"; echo "HEV_ABIS=armeabi-v7a" >> "$GITHUB_ENV" ;; + x86) echo "GOMOBILE_TARGET=android/386" >> "$GITHUB_ENV"; echo "HEV_ABIS=x86" >> "$GITHUB_ENV" ;; + x86_64) echo "GOMOBILE_TARGET=android/amd64" >> "$GITHUB_ENV"; echo "HEV_ABIS=x86_64" >> "$GITHUB_ENV" ;; + all) echo "HEV_ABIS=armeabi-v7a arm64-v8a x86 x86_64" >> "$GITHUB_ENV" ;; + *) echo "::error::未知 ABI: $ABI"; exit 1 ;; + esac + echo "ABI=$ABI -> gomobile target=${GOMOBILE_TARGET:-全部}, hevtun ABI=${HEV_ABIS}" + - name: Compute native cache keys run: | find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status @@ -140,9 +163,9 @@ jobs: uses: actions/cache/restore@v4 with: path: app/libs/libcore.aar - key: libcore-${{ runner.os }}-${{ hashFiles('golang_status', 'libcore_status') }} + key: libcore-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('golang_status', 'libcore_status') }} restore-keys: | - libcore-${{ runner.os }}- + libcore-${{ runner.os }}-${{ env.ABI }}- - name: Setup Golang if: steps.libcore-cache.outputs.cache-hit != 'true' @@ -166,9 +189,9 @@ jobs: uses: actions/cache/restore@v4 with: path: app/src/main/jniLibs - key: hevtun-${{ runner.os }}-${{ hashFiles('hev_status') }} + key: hevtun-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('hev_status') }} restore-keys: | - hevtun-${{ runner.os }}- + hevtun-${{ runner.os }}-${{ env.ABI }}- - name: Build hevtun (NDK, 4 ABIs) if: steps.hevtun-cache.outputs.cache-hit != 'true' @@ -181,14 +204,14 @@ jobs: uses: actions/cache/save@v4 with: path: app/libs/libcore.aar - key: libcore-${{ runner.os }}-${{ hashFiles('golang_status', 'libcore_status') }} + key: libcore-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('golang_status', 'libcore_status') }} - name: Save hevtun cache if: steps.hevtun-cache.outputs.cache-hit != 'true' && success() uses: actions/cache/save@v4 with: path: app/src/main/jniLibs - key: hevtun-${{ runner.os }}-${{ hashFiles('hev_status') }} + key: hevtun-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('hev_status') }} - name: Save Android SDK cache if: steps.sdk-cache.outputs.cache-hit != 'true' && success() @@ -356,10 +379,16 @@ jobs: TASK="assemble$(cap "$FLAVOR")$(cap "$BT")" echo "TASK=$TASK" >> "$GITHUB_ENV" echo "EFFECTIVE_TYPE=$BT" >> "$GITHUB_ENV" - echo "==> ./gradlew app:$TASK $GRADLE_ARGS" + + # 只构建目标 ABI,避免产出 4 个 split APK + ABI_ARGS=() + if [ "$ABI" != "all" ]; then + ABI_ARGS=("-Pandroid.injected.build.abi=$ABI") + fi + echo "==> ./gradlew app:$TASK ${ABI_ARGS[*]:-} $GRADLE_ARGS" set +e - ./gradlew "app:$TASK" $GRADLE_ARGS 2>&1 | tee gradle.log + ./gradlew "app:$TASK" ${ABI_ARGS[@]+"${ABI_ARGS[@]}"} $GRADLE_ARGS 2>&1 | tee gradle.log status=${PIPESTATUS[0]} if [ "$status" -ne 0 ]; then # 把编译错误转成 annotation,便于在 Checks 页直接看到 @@ -378,20 +407,34 @@ jobs: ls -R app/build/outputs/apk || true exit 1 fi - COUNT=$(find "$OUT" -name '*.apk' | wc -l) + if [ "$ABI" = "all" ]; then + PATTERN="*.apk" + else + PATTERN="*${ABI}*.apk" + fi + FILES=$(find "$OUT" -name "$PATTERN" | sort) + COUNT=$(printf '%s\n' "$FILES" | grep -c . || true) if [ "$COUNT" -eq 0 ]; then - echo "::error::$OUT 下没有 APK" + echo "::error::$OUT 下没有匹配 $PATTERN 的 APK" + ls -lh "$OUT" || true exit 1 fi echo "dir=$OUT" >> "$GITHUB_OUTPUT" + { + echo "files<> "$GITHUB_OUTPUT" echo "=== 产物($COUNT 个)===" - ls -lh "$OUT" + printf '%s\n' "$FILES" | while IFS= read -r f; do ls -lh "$f"; done { - echo "### APK 产物:$FLAVOR / $EFFECTIVE_TYPE" + echo "### APK 产物:$FLAVOR / $EFFECTIVE_TYPE / $ABI" echo "" echo "| 文件 | 大小 |" echo "| --- | --- |" - find "$OUT" -name '*.apk' -printf '| %f | %k KB |\n' + while IFS= read -r f; do + [ -n "$f" ] && printf '| %s | %s KB |\n' "$(basename "$f")" "$(du -k "$f" | cut -f1)" + done <<< "$FILES" } >> "$GITHUB_STEP_SUMMARY" - name: Verify APK signature @@ -399,16 +442,17 @@ jobs: run: | APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | tail -1) echo "使用 $APKSIGNER" - for apk in ${{ steps.collect.outputs.dir }}/*.apk; do + printf '%s\n' "${{ steps.collect.outputs.files }}" | while IFS= read -r apk; do + [ -n "$apk" ] || continue echo "== $apk" - "$APKSIGNER" verify --print-certs "$apk" + "$APKSIGNER" verify "$apk" done - name: Upload APK uses: actions/upload-artifact@v7 with: - name: NekoBox-${{ env.FLAVOR }}-${{ env.EFFECTIVE_TYPE }} - path: ${{ steps.collect.outputs.dir }}/*.apk + name: NekoBox-${{ env.FLAVOR }}-${{ env.EFFECTIVE_TYPE }}-${{ env.ABI }} + path: ${{ steps.collect.outputs.files }} retention-days: 7 if-no-files-found: error diff --git a/buildScript/compile-hevtun.sh b/buildScript/compile-hevtun.sh index 9aeb37ef23..ae3437f7dc 100755 --- a/buildScript/compile-hevtun.sh +++ b/buildScript/compile-hevtun.sh @@ -29,7 +29,7 @@ source "$SCRIPT_DIR/init/env_ndk.sh" HEV_VERSION="${HEV_VERSION:-2.17.1}" HEV_SRC="${HEV_SRC:-$REPO_DIR/hev-socks5-tunnel}" -ABIS="armeabi-v7a arm64-v8a x86 x86_64" +ABIS="${HEV_ABIS:-armeabi-v7a arm64-v8a x86 x86_64}" OUT_DIR="$REPO_DIR/app/src/main/jniLibs" BUILD_DIR="$REPO_DIR/.hev-build" diff --git a/libcore/build.sh b/libcore/build.sh index 233e89d4d4..71053fa6be 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -15,7 +15,12 @@ if [ -z "$GOPATH" ]; then fi export GOBIND=gobind-matsuri -"$GOPATH"/bin/gomobile-matsuri bind -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags='-s -w' -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 +# 可选:GOMOBILE_TARGET 限定 ABI(如 "android/arm64"),不设置时保持原有全量编译 +TARGET_ARGS=() +if [ -n "${GOMOBILE_TARGET:-}" ]; then + TARGET_ARGS=("-target=$GOMOBILE_TARGET") +fi +"$GOPATH"/bin/gomobile-matsuri bind "${TARGET_ARGS[@]}" -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags='-s -w' -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 rm -r libcore-sources.jar proj=../app/libs From 6d6999b3c2de800a91d262e743ca257b508ec239 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:41:15 +0000 Subject: [PATCH 03/62] ci: unset empty signing secrets for debug builds too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub 在 secret 未配置时注入空字符串,Helpers.kt 的 ?: 判断不到 null,会创建密码/别名为空的 signingConfig,导致 debug 构建 packageOssDebug 签名失败(Failed to read key ... password was incorrect)。改为只要三项不全就 unset,release 顺带回退 debug,并在 Prepare 步骤打印各 secret 是否为空。 --- .github/workflows/build-apk.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 9b7a63d97c..801440ed6a 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -335,6 +335,9 @@ jobs: echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties echo "ndk.dir=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> local.properties # buildSrc/Helpers.kt 读取 release.keystore(已在仓库内),密码来自 env 或 local.properties + for v in KEYSTORE_PASS ALIAS_NAME ALIAS_PASS; do + if [ -n "${!v:-}" ]; then echo "secret $v: 已配置"; else echo "secret $v: 空(未配置)"; fi + done if [ -n "$KEYSTORE_PASS" ] && [ -n "$ALIAS_NAME" ] && [ -n "$ALIAS_PASS" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" echo "release 签名已就绪(release.keystore)" @@ -370,11 +373,18 @@ jobs: cap() { echo "${1^}"; } BT="$BUILD_TYPE" - if [ "$BT" = "release" ] && [ -z "${KEYSTORE_PASS:-}" ]; then - # 空字符串会让 Helpers.kt 创建密码为空的 signingConfig,必须 unset + # 注意:secret 未配置时 GitHub 注入的是空字符串而非 null, + # Helpers.kt 里 `?:` 判断不到 null 会创建密码/别名为空的 signingConfig, + # 连 debug 构建都会被它拖挂(Failed to read key ... password was incorrect)。 + # 所以无论构建类型是什么,只要三项不全就彻底 unset。 + if [ -z "${KEYSTORE_PASS:-}" ] || [ -z "${ALIAS_NAME:-}" ] || [ -z "${ALIAS_PASS:-}" ]; then unset KEYSTORE_PASS ALIAS_NAME ALIAS_PASS - BT="debug" - echo "::warning::未配置签名密钥,已自动改为 debug 构建(APK 用 debug key 签名,可直接安装)" + if [ "$BT" = "release" ]; then + BT="debug" + echo "::warning::签名 secrets 不完整,release 已自动改为 debug 构建" + else + echo "签名 secrets 不完整,使用 AGP 默认 debug keystore 签名" + fi fi TASK="assemble$(cap "$FLAVOR")$(cap "$BT")" echo "TASK=$TASK" >> "$GITHUB_ENV" From 30369141cfe0dcc43a96afe3dbeafe8290bb0967 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:41:34 +0000 Subject: [PATCH 04/62] feat: add per-profile URL test button next to edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在节点卡片 edit 按钮旁新增测速按钮,点击直接用 UrlTest().doTest() 测当前节点延迟,状态码与全量 URL Test 一致(1 成功/2 插件缺失/3 失败),完成后只刷新该行;双列与选择模式下跟随 edit 隐藏。 --- .../sagernet/ui/ConfigurationFragment.kt | 50 +++++++++++++++++++ app/src/main/res/layout/layout_profile.xml | 11 ++++ 2 files changed, 61 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a6734cd9fa..d9921ed414 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2194,6 +2194,7 @@ class ConfigurationFragment @JvmOverloads constructor( private val card = view as MaterialCardView private val selectedIndicator: View = view.findViewById(R.id.selected_indicator) val editButton: ImageView = view.findViewById(R.id.edit) + val urlTestButton: ImageView = view.findViewById(R.id.url_test) val doubleColumnMenuButton: ImageView = view.findViewById(R.id.double_column_menu) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) @@ -2224,6 +2225,9 @@ class ConfigurationFragment @JvmOverloads constructor( ) ) } + urlTestButton.setOnClickListener { + startUrlTest(entity) + } removeButton.setOnClickListener { removeProfile(entity) } @@ -2238,6 +2242,50 @@ class ConfigurationFragment @JvmOverloads constructor( } } + private var urlTestRunning = false + + /** 对当前这一个节点做 URL Test,完成后就地刷新该行状态 */ + private fun startUrlTest(proxyEntity: ProxyEntity) { + if (urlTestRunning || select) return + urlTestRunning = true + urlTestButton.isEnabled = false + profileStatus.setTextColor( + requireContext().getColorAttr(android.R.attr.textColorSecondary) + ) + profileStatus.text = getString(R.string.connection_test_testing) + val targetId = proxyEntity.id + + runOnDefaultDispatcher { + try { + val ping = UrlTest().doTest(proxyEntity) + proxyEntity.status = 1 + proxyEntity.ping = ping + proxyEntity.error = null + } catch (e: PluginManager.PluginNotFoundException) { + proxyEntity.status = 2 + proxyEntity.error = e.readableMessage + } catch (e: Exception) { + proxyEntity.status = 3 + proxyEntity.error = e.readableMessage + } + try { + ProfileManager.updateProfile(proxyEntity) + } catch (e: Exception) { + Logs.w(e) + } + runOnMainDispatcher { + urlTestRunning = false + if (::entity.isInitialized && entity.id == targetId) { + urlTestButton.isEnabled = true + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + adapter?.notifyItemChanged(pos) + } + } + } + } + } + private fun selectProfile(proxyEntity: ProxyEntity) { val pf = parentFragment as? ConfigurationFragment ?: return runOnDefaultDispatcher { @@ -2425,12 +2473,14 @@ class ConfigurationFragment @JvmOverloads constructor( if (isDoubleColumn) { editButton.isGone = true + urlTestButton.isGone = true shareLayout.isGone = true removeButton.isGone = true doubleColumnMenuButton.isVisible = true } else { shareLayout.isGone = selectOrChain editButton.isGone = select + urlTestButton.isGone = select removeButton.isGone = select doubleColumnMenuButton.isGone = true } diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index de0669c10d..3c229e2a20 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -53,6 +53,17 @@ tools:text="@string/profile_name" /> + + Date: Thu, 5 Nov 2026 07:50:36 +0000 Subject: [PATCH 05/62] feat: add per-app proxy (Apps VPN) entry to drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把设置页里的「分应用代理」提到侧边栏,位置在 路由 与 设置 之间;点击直接进入 AppManagerActivity 选择应用,菜单项放在 checkableBehavior=none 的独立 group 里,避免点击后改变导航选中态。 --- .../io/nekohasekai/sagernet/ui/MainActivity.kt | 6 ++++++ app/src/main/res/menu/main_drawer_menu.xml | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt index 5b11495849..d747f74fb2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -425,6 +425,12 @@ class MainActivity : ThemedActivity(), R.id.nav_group -> displayFragment(GroupFragment()) R.id.nav_route -> displayFragment(RouteFragment()) + R.id.nav_apps -> { + binding.drawerLayout.closeDrawers() + startActivity(Intent(this, AppManagerActivity::class.java)) + return false + } + R.id.nav_settings -> displayFragment(SettingsFragment()) R.id.nav_traffic -> displayFragment(WebviewFragment()) R.id.nav_tools -> displayFragment(ToolsFragment()) diff --git a/app/src/main/res/menu/main_drawer_menu.xml b/app/src/main/res/menu/main_drawer_menu.xml index 040e8e002f..f7dded62c2 100644 --- a/app/src/main/res/menu/main_drawer_menu.xml +++ b/app/src/main/res/menu/main_drawer_menu.xml @@ -16,7 +16,21 @@ android:id="@+id/nav_route" android:icon="@drawable/ic_maps_directions" android:title="@string/menu_route" /> + + + + + + + Date: Thu, 5 Nov 2026 07:52:39 +0000 Subject: [PATCH 06/62] feat: clear previous test results before starting URL test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开始 URL Test 前先把当前分组所有节点的 status/ping/error 归零并写库、同步清空 adapter,避免测试中途取消时旧结果与新结果混在一起显示。 --- .../sagernet/ui/ConfigurationFragment.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index d9921ed414..0dc0b7a5f2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1146,6 +1146,28 @@ class ConfigurationFragment @JvmOverloads constructor( val mainJob = runOnDefaultDispatcher { val profilesList = SagerDatabase.proxyDao.getByGroup(group.id) + // 开始测试前先清理上一轮的测试结果,避免中途取消时新旧状态混在一起 + if (profilesList.isNotEmpty()) { + var cleared = false + for (profile in profilesList) { + if (profile.status != 0) { + profile.status = 0 + profile.ping = 0 + profile.error = null + cleared = true + } + } + if (cleared) { + try { + ProfileManager.updateProfile(profilesList) + } catch (e: Exception) { + Logs.w(e) + } + } + onMainDispatcher { + getCurrentGroupFragment()?.adapter?.clearTestResults() + } + } test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) repeat(DataStore.connectionTestConcurrent) { From 91b9c191936e1be505b15c974bc7737c7e70e47e Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:55:29 +0000 Subject: [PATCH 07/62] ci: stop duplicate workflow runs on main push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci.yml 原本 push 到任意分支就触发,与 build-apk.yml 重复;改为仅 pull_request + 手动。build.yml 的 nb4a.properties 路径触发也与 build-apk.yml 重叠,改为仅手动。现在 push main 只会跑 build-apk.yml 一个。 --- .github/workflows/build.yml | 7 ++----- .github/workflows/ci.yml | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98f242f7a9..05f43e24a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,8 @@ name: Build on: + # 版本号变更的构建已由 build-apk.yml 覆盖(它的 paths 含 nb4a.properties), + # 这里改为仅手动触发,避免同一次 push 触发两个 workflow workflow_dispatch: - push: - branches: - - main - paths: - - 'nb4a.properties' jobs: libcore: name: Native Build (LibCore) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31a00ba003..2d2d6a832c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,8 @@ name: CI on: - push: - tags-ignore: - - 'v*' - branches: - - '*' + # main 分支的构建统一由 build-apk.yml 负责,这里只在 PR 上做校验,避免同一提交跑两遍 pull_request: + workflow_dispatch: jobs: libcore: name: Native Build (LibCore) From 24ad2a7bfd3449fceb002032f5ec8fe7dc1acd8c Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 07:58:35 +0000 Subject: [PATCH 08/62] ci: document workflow responsibilities in build-apk.yml --- .github/workflows/build-apk.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 801440ed6a..3c524e951b 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -1,5 +1,11 @@ name: Android CI +# main 分支唯一的构建入口。 +# ci.yml 仅 pull_request 触发(PR 校验) +# build.yml 仅手动触发(原 nb4a.properties 触发已与这里重叠,已移除) +# preview.yml / release.yml 仅手动触发 +# 这样一次 push 只会产生一条运行记录,避免重复构建。 +# # 编译 NekoBox APK 的完整流水线: # native job -> 生成 libcore.aar(Go/gomobile)与 hev-tun 的 .so(NDK) # build job -> 下载 native 产物 + 下载 geo 资源 + Gradle 打包 APK From c670e6d4e574e1017f699b9737fd37c0f8c381b3 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:04:04 +0000 Subject: [PATCH 09/62] fix: build single ABI via NB4A_ABI env instead of injected property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - -Pandroid.injected.build.abi 在 AGP 8.8 上导致 assemble 成功但不产出 APK(outputs/apk 目录不存在) - 改为 buildSrc 读环境变量 NB4A_ABI 限制 splits.abi,默认不设置时仍编译全部 ABI - Collect APKs 改为全量搜索 outputs 并打印诊断信息,不再死绑 flavor 目录 --- .github/workflows/build-apk.yml | 40 ++++++++++++++++------------- buildSrc/src/main/kotlin/Helpers.kt | 14 +++++++--- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 3c524e951b..246672cfda 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -396,15 +396,17 @@ jobs: echo "TASK=$TASK" >> "$GITHUB_ENV" echo "EFFECTIVE_TYPE=$BT" >> "$GITHUB_ENV" - # 只构建目标 ABI,避免产出 4 个 split APK - ABI_ARGS=() - if [ "$ABI" != "all" ]; then - ABI_ARGS=("-Pandroid.injected.build.abi=$ABI") + # 只构建目标 ABI:buildSrc/Helpers.kt 读 NB4A_ABI 限制 splits + # (原先用 -Pandroid.injected.build.abi,AGP 8.8 上会构建成功但不出 APK) + if [ "$ABI" = "all" ]; then + unset NB4A_ABI + else + export NB4A_ABI="$ABI" fi - echo "==> ./gradlew app:$TASK ${ABI_ARGS[*]:-} $GRADLE_ARGS" + echo "==> NB4A_ABI=${NB4A_ABI:-全部} ./gradlew app:$TASK $GRADLE_ARGS" set +e - ./gradlew "app:$TASK" ${ABI_ARGS[@]+"${ABI_ARGS[@]}"} $GRADLE_ARGS 2>&1 | tee gradle.log + ./gradlew "app:$TASK" $GRADLE_ARGS 2>&1 | tee gradle.log status=${PIPESTATUS[0]} if [ "$status" -ne 0 ]; then # 把编译错误转成 annotation,便于在 Checks 页直接看到 @@ -417,25 +419,27 @@ jobs: - name: Collect APKs id: collect run: | - OUT="app/build/outputs/apk/${FLAVOR}/${EFFECTIVE_TYPE}" - if [ ! -d "$OUT" ]; then - echo "::error::未找到 APK 输出目录 $OUT" - ls -R app/build/outputs/apk || true - exit 1 - fi + BASE="app/build/outputs/apk" if [ "$ABI" = "all" ]; then PATTERN="*.apk" else PATTERN="*${ABI}*.apk" fi - FILES=$(find "$OUT" -name "$PATTERN" | sort) - COUNT=$(printf '%s\n' "$FILES" | grep -c . || true) - if [ "$COUNT" -eq 0 ]; then - echo "::error::$OUT 下没有匹配 $PATTERN 的 APK" - ls -lh "$OUT" || true + FILES="" + if [ -d "$BASE" ]; then + FILES=$(find "$BASE" -name "$PATTERN" | sort) + fi + if [ -z "$FILES" ]; then + echo "::error::$BASE 下没有匹配 $PATTERN 的 APK" + echo "--- 实际产出的所有 APK ---" + find app/build -maxdepth 6 -name '*.apk' 2>/dev/null || true + echo "--- outputs 目录结构 ---" + find app/build/outputs -maxdepth 4 -type d 2>/dev/null | head -30 || true + echo "--- gradle 日志尾部 ---" + tail -30 gradle.log 2>/dev/null || true exit 1 fi - echo "dir=$OUT" >> "$GITHUB_OUTPUT" + COUNT=$(printf '%s\n' "$FILES" | grep -c . || true) { echo "files< Date: Thu, 5 Nov 2026 08:25:23 +0000 Subject: [PATCH 10/62] build: add fixed debug keystore for stable APK signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前每次构建都用 AGP 自动生成的 debug key,签名不同导致新包无法覆盖安装。改为使用仓库内固定的 debug.keystore(alias/pass 均为 nekobox,SHA-256 6A:B1:8C:F5...),且 release 密码为空字符串时也视为未配置。 --- buildSrc/src/main/kotlin/Helpers.kt | 38 ++++++++++++++++++++++------ debug.keystore | Bin 0 -> 2730 bytes 2 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 debug.keystore diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 914a4318a8..26297726e1 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -115,12 +115,17 @@ fun Project.setupAppCommon() { setupCommon() val lp = requireLocalProperties() - val keystorePwd = lp.getProperty("KEYSTORE_PASS") ?: System.getenv("KEYSTORE_PASS") - val alias = lp.getProperty("ALIAS_NAME") ?: System.getenv("ALIAS_NAME") - val pwd = lp.getProperty("ALIAS_PASS") ?: System.getenv("ALIAS_PASS") + // 空字符串也要当成「未设置」:CI 里未配置 secret 时注入的就是空串 + val keystorePwd = (lp.getProperty("KEYSTORE_PASS") ?: System.getenv("KEYSTORE_PASS")) + ?.takeIf { it.isNotBlank() } + val alias = (lp.getProperty("ALIAS_NAME") ?: System.getenv("ALIAS_NAME")) + ?.takeIf { it.isNotBlank() } + val pwd = (lp.getProperty("ALIAS_PASS") ?: System.getenv("ALIAS_PASS")) + ?.takeIf { it.isNotBlank() } + val fixedDebugKeystore = rootProject.file("debug.keystore") android.apply { - if (keystorePwd != null) { + if (keystorePwd != null && alias != null && pwd != null) { signingConfigs { create("release") { storeFile = rootProject.file("release.keystore") @@ -129,12 +134,29 @@ fun Project.setupAppCommon() { keyPassword = pwd } } + } else if (fixedDebugKeystore.isFile) { + // 仓库内固定的调试证书:让每次构建的签名一致,新包可以直接覆盖安装 + signingConfigs { + create("fixedDebug") { + storeFile = fixedDebugKeystore + storePassword = "nekobox" + keyAlias = "nekobox" + keyPassword = "nekobox" + } + } } buildTypes { - val key = signingConfigs.findByName("release") - if (key != null) { - getByName("release").signingConfig = key - getByName("debug").signingConfig = key + val releaseKey = signingConfigs.findByName("release") + val fixedKey = signingConfigs.findByName("fixedDebug") + when { + releaseKey != null -> { + getByName("release").signingConfig = releaseKey + getByName("debug").signingConfig = releaseKey + } + + fixedKey != null -> { + getByName("debug").signingConfig = fixedKey + } } } } diff --git a/debug.keystore b/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..4b0319fbc8ed34eba0a4005caa5345856270ac7a GIT binary patch literal 2730 zcma);X*d*$8pmhGjAbliU(#5Lk+m7SvPLr_3?kVEWl6~rLgiRS$i5fCVC-8*gJg}d zh3wgClI+Vt$jl+R&U5c`&T~KA`{Dn*&-?$s@B8uhyhsAH1O#M25};;`EHY8pC>lGE z37AiS>VXMRty5bANr3$RUlF7LOn~H`+F7T{U}XKz78?`@$|pc{Pfbmv+us`ycBBnb z{hyW%DGTQ9q>qzZW-s?-RsXbTD=;NR>XOGnASq4|Pz=e&$o%h%AQlJ!31?(+ioyb2 z8NfgpFlPsgIJ{*Kf5<2!=k$#3Rs$x0N5F2mQ)>qnf=hDG?O?vTFRGLS97!={G1PCZ zQFh@Ny?QoLarM|~2_cgluI@bPcRti`gH&ldW?V8Y@Liq(b&c`Q7&%@R?$S4NM*d93 zwLj%C9Vqle$9S<d|_6MeT9+*VW3G?_DDFjzR?q&a%R%n-jiMG(%k z&GvFvy$LBh)4-gH>e(#yUuPU+OFnAfn37BDc5^x?;T&G?L2KpgOgX>jUyWFUnn|g( z2C%9;acOk_Bia)g(^X1ZHM&umNx&~K{ z&J~?E#tAENXGRw{sM&vV$Tt>taNax|2YUxB`W1%v5qRtl4rK;LDmBQIr234f5xY|5 z3VU%<<71Rom*I%^DD4`(Ji`s~2Ol$>i2^$r$RiuN$3!#-KCx^DlbnP1++z9%n2FF+|YL&gGq znfDEBVH!Qt_>EW&=6f-2qUePsrNz{0*@3-&QhQr`mziuHUqL#;I{5v!#($x8G*~b&q z+#Wle3}T)Bxx%`|MJXdOYnFp{Wyii`jv@KVN+7j~r-GQ6U5wPxzgKQ9RTr)yKqT>c z`Vp5~j)(Bl*;43?#tJTDNkon$t|+SRORINv|HwN))-&wLk6pFkk)4C4KV9q!yC2wUp(#H%7eaq{EY~#*-!gB~k}J)KFC6 zZMv4g`dR_p%( z_1Qc~>74(0Us%tcVWmsGau|VOr#ae)ZtH)4(l_V*F>>*cAHZkw=~#VBHJEE$c`(2W zwjIr}niQCMMB1!Sw4vN|6GE3u+1KSX_ zzAvKK)&!zbH0hbdNJ|Pjc}O!$a2>aeB$?%pxKr)yVn?hY~5ONzEHfT*Sw};D@%zn zpmj;V77#SE-nNf}FJNv(B?zJ~1<;=tHI2ro^M9E#GE4a|P)wFV^Qv7(0mn}W%Ow#a z!`>$g)?cP9%Qy@>{U1Cpal9eC?w)ATz4LNK;HgDpuA)HP4NrqS!h2WnhSeD!=@RkOZ7+arowiAg#jg? zoOsZ`%>o=tL%u!7@1ZdE=xGg+dzZ0a@n#M+3_q}vx)m%KZO>9Fr=>YoTfzm*zSPjIE}LygFaSU0eMk$KY4mWK{?sGAyX2D{L0DVlKJz`>#w?99&o+ zI#LlV(Q1+ZnHt%iWYY8h{l1%CjiSvMdqAiHW(sTFKxS5q*_*tFrV0AISXgCcs zTw1i8!{L1KM1d!2KJ1Dqr87tSh#UAgug=GKxe**y_ z0I-;_oZyt|E$oGz-n$7-LXxE)rw$i)GlgokKS+-5Soa@;d1n{PkA@iKC(b&=$0)3U NIwi$k`Td2Qe*wWy>FNLg literal 0 HcmV?d00001 From eab4e4bcfc7f2be8c4ce9fef10f44bf97c823144 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:25:47 +0000 Subject: [PATCH 11/62] feat: long press menu with multi-select move to group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 长按节点卡片弹出菜单(移动分组/多选/调整顺序/编辑/分享/删除)。进入多选后工具栏变为「移动到分组 + 全选 + 关闭」,点击卡片切换选中(绿描边),支持批量移动到其它分组并刷新各个已创建分组页面。长按拖拽排序改为菜单项触发,避免与长按菜单冲突。 --- .../sagernet/ui/ConfigurationFragment.kt | 267 +++++++++++++++++- .../main/res/menu/profile_long_click_menu.xml | 21 ++ .../res/menu/profile_multi_select_menu.xml | 18 ++ app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + 5 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/menu/profile_long_click_menu.xml create mode 100644 app/src/main/res/menu/profile_multi_select_menu.xml diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 0dc0b7a5f2..62b814080a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -339,17 +339,7 @@ class ConfigurationFragment @JvmOverloads constructor( } } - val searchView = toolbar.findViewById(R.id.action_search) - if (searchView != null) { - searchView.setOnQueryTextListener(this) - searchView.maxWidth = Int.MAX_VALUE - - searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - cancelSearch(searchView) - } - } - } + setupSearchView() groupPager = view.findViewById(R.id.group_pager) tabLayout = view.findViewById(R.id.group_tab) @@ -863,6 +853,29 @@ class ConfigurationFragment @JvmOverloads constructor( urlTest() } + R.id.action_move_to_group -> { + val currentAdapter = getCurrentGroupFragment()?.adapter + if (currentAdapter != null) { + moveProfilesToGroup( + currentAdapter.selectedIds.mapNotNull { currentAdapter.configurationList[it] } + ) + } + } + + R.id.action_select_all -> { + val currentAdapter = getCurrentGroupFragment()?.adapter + if (currentAdapter != null) { + currentAdapter.selectedIds.clear() + currentAdapter.selectedIds.addAll(currentAdapter.configurationIdList) + currentAdapter.notifyDataSetChanged() + updateSelectionUi() + } + } + + R.id.action_exit_select -> { + exitSelectionMode() + } + R.id.action_global_mode -> { item.isChecked = !item.isChecked DataStore.globalMode = item.isChecked @@ -1136,6 +1149,129 @@ class ConfigurationFragment @JvmOverloads constructor( } } + // ---------------- 多选 & 移动分组 ---------------- + + fun isSelectionMode(): Boolean = getCurrentGroupFragment()?.adapter?.selectionMode == true + + fun enterSelectionMode(initialId: Long) { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + currentAdapter.selectionMode = true + currentAdapter.selectedIds.clear() + currentAdapter.selectedIds.add(initialId) + currentAdapter.notifyDataSetChanged() + updateSelectionUi() + } + + fun exitSelectionMode() { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + currentAdapter.selectionMode = false + currentAdapter.selectedIds.clear() + currentAdapter.notifyDataSetChanged() + restoreToolbarMenu() + } + + fun toggleSelection(proxyEntity: ProxyEntity) { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + if (!currentAdapter.selectionMode) return + if (!currentAdapter.selectedIds.remove(proxyEntity.id)) { + currentAdapter.selectedIds.add(proxyEntity.id) + } + if (currentAdapter.selectedIds.isEmpty()) { + // 取消最后一个即退出多选 + exitSelectionMode() + return + } + val index = currentAdapter.configurationIdList.indexOf(proxyEntity.id) + if (index >= 0) currentAdapter.notifyItemChanged(index) + updateSelectionUi() + } + + private fun updateSelectionUi() { + val currentAdapter = getCurrentGroupFragment()?.adapter ?: return + if (toolbar.menu.findItem(R.id.action_select_all) == null) { + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.profile_multi_select_menu) + toolbar.setOnMenuItemClickListener(this) + } + toolbar.title = getString(R.string.selected_count, currentAdapter.selectedIds.size) + } + + private fun setupSearchView() { + val sv = toolbar.findViewById(R.id.action_search) ?: return + sv.setOnQueryTextListener(this) + sv.maxWidth = Int.MAX_VALUE + sv.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + cancelSearch(sv) + } + } + } + + private fun restoreToolbarMenu() { + if (select) return + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.add_profile_menu) + toolbar.menu.findItem(R.id.action_global_mode)?.isChecked = DataStore.globalMode + toolbar.setOnMenuItemClickListener(this) + toolbar.title = getString(R.string.app_name) + // 菜单被重新 inflate,SearchView 需要重新绑定监听 + setupSearchView() + } + + fun moveProfilesToGroup(profiles: List) { + if (profiles.isEmpty()) return + val sourceGroupId = profiles.first().groupId + runOnDefaultDispatcher { + val groups = SagerDatabase.groupDao.allGroups().filter { it.id != sourceGroupId } + onMainDispatcher { + if (groups.isEmpty()) { + snackbar(getString(R.string.no_other_group)).show() + return@onMainDispatcher + } + val names = groups.map { it.displayName() }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.move_to_group) + .setItems(names) { _, which -> + performMoveProfiles(profiles, groups[which]) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + } + } + + private fun performMoveProfiles(profiles: List, target: ProxyGroup) { + runOnDefaultDispatcher { + try { + for (profile in profiles) { + profile.groupId = target.id + profile.userOrder = SagerDatabase.proxyDao.nextOrder(target.id) ?: 1 + ProfileManager.updateProfile(profile) + } + onMainDispatcher { + val currentAdapter = getCurrentGroupFragment()?.adapter + currentAdapter?.apply { + selectionMode = false + selectedIds.clear() + } + restoreToolbarMenu() + snackbar( + getString(R.string.moved_to_group, profiles.size, target.displayName()) + ).show() + } + // 已创建的分组页面都从库里重新加载(reloadProfiles 会直接访问数据库,不能放主线程) + for (fragment in adapter.groupFragments.values.toList()) { + fragment.adapter?.reloadProfiles() + } + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { + snackbar(e.readableMessage).show() + } + } + } + } + @OptIn(DelicateCoroutinesApi::class) fun urlTest() { if (DataStore.runningTest) return else DataStore.runningTest = true @@ -1434,6 +1570,9 @@ class ConfigurationFragment @JvmOverloads constructor( } itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, 0) { + // 长按改为弹菜单,拖拽排序由菜单里的「调整顺序」手动启动 + override fun isLongPressDragEnabled(): Boolean = false + override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder @@ -1720,6 +1859,31 @@ class ConfigurationFragment @JvmOverloads constructor( private val pendingTrafficUpdates = HashSet() private val profileStatePayload = Any() + // 多选移动分组 + var selectionMode = false + val selectedIds = LinkedHashSet() + + fun startDrag(holder: RecyclerView.ViewHolder) { + if (::itemTouchHelper.isInitialized) itemTouchHelper.startDrag(holder) + } + + fun removeProfiles(ids: Collection) { + var removed = false + for (id in ids) { + val index = configurationIdList.indexOf(id) + if (index >= 0) { + configurationIdList.removeAt(index) + configurationList.remove(id) + notifyItemRemoved(index) + removed = true + } + } + if (removed) { + refreshFromPosition(0) + notifyItemRangeChanged(0, itemCount) + } + } + private fun getItem(profileId: Long): ProxyEntity { var profile = configurationList[profileId] if (profile == null) { @@ -2226,12 +2390,28 @@ class ConfigurationFragment @JvmOverloads constructor( init { view.setOnClickListener { val proxyEntity = entity + val owner = parentFragment as? ConfigurationFragment + if (owner != null && owner.isSelectionMode()) { + owner.toggleSelection(proxyEntity) + return@setOnClickListener + } if (select) { (requireActivity() as SelectCallback).returnProfile(proxyEntity.id) } else { selectProfile(proxyEntity) } } + view.setOnLongClickListener { + if (select) return@setOnLongClickListener false + val owner = parentFragment as? ConfigurationFragment + ?: return@setOnLongClickListener false + if (owner.isSelectionMode()) { + owner.toggleSelection(entity) + } else { + showProfileMenu(view, entity, this) + } + true + } profileStatus.setOnClickListener { val proxyEntity = entity if (proxyEntity.status == 3) { @@ -2386,6 +2566,57 @@ class ConfigurationFragment @JvmOverloads constructor( popup.show() } + /** 长按节点卡片的菜单:移动分组 / 多选 / 排序 / 编辑 / 分享 / 删除 */ + private fun showProfileMenu( + anchor: View, + proxyEntity: ProxyEntity, + holder: ConfigurationHolder, + ) { + val owner = parentFragment as? ConfigurationFragment ?: return + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.profile_long_click_menu, popup.menu) + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_move_to_group -> { + owner.moveProfilesToGroup(listOf(proxyEntity)) + true + } + + R.id.action_multi_select -> { + owner.enterSelectionMode(proxyEntity.id) + true + } + + R.id.action_drag_sort -> { + adapter?.startDrag(holder) + true + } + + R.id.action_edit -> { + anchor.context.startActivity( + proxyEntity.settingIntent( + anchor.context, proxyGroup.type == GroupType.SUBSCRIPTION + ) + ) + true + } + + R.id.action_share -> { + showShareMenu(anchor, proxyEntity) + true + } + + R.id.action_delete -> { + removeProfile(proxyEntity) + true + } + + else -> false + } + } + popup.show() + } + private fun applySelected(selected: Boolean) { val ctx = card.context val surface = ctx.getColorAttr(R.attr.colorSurface) @@ -2520,6 +2751,20 @@ class ConfigurationFragment @JvmOverloads constructor( removeButton.isEnabled = !started applySelected(selected) + // 多选高亮:只覆盖选中态,未选中保持 applySelected 的描边 + val inSelection = adapter?.selectionMode == true + val picked = inSelection && adapter?.selectedIds?.contains(proxyEntity.id) == true + if (picked) { + card.strokeWidth = (2 * resources.displayMetrics.density).toInt() + card.strokeColor = requireContext().getColour(R.color.material_green_500) + } + if (inSelection) { + // 多选时隐藏操作按钮,避免误触编辑/删除 + editButton.isGone = true + removeButton.isGone = true + shareLayout.isGone = true + } + if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { shareLayer.setBackgroundColor(Color.TRANSPARENT) shareButton.setImageResource(R.drawable.ic_social_share) diff --git a/app/src/main/res/menu/profile_long_click_menu.xml b/app/src/main/res/menu/profile_long_click_menu.xml new file mode 100644 index 0000000000..25f831ef7c --- /dev/null +++ b/app/src/main/res/menu/profile_long_click_menu.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/main/res/menu/profile_multi_select_menu.xml b/app/src/main/res/menu/profile_multi_select_menu.xml new file mode 100644 index 0000000000..64d095a18f --- /dev/null +++ b/app/src/main/res/menu/profile_multi_select_menu.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 16b353acd5..5b8c8ac2a6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -610,4 +610,13 @@ QUIC Proxy 模式 仅 v6:旧版 v5 QUIC Proxy 兼容 仅适用于 v4-v6 + + + 移动到分组 + 多选 + 调整顺序 + 全选 + 已选 %d 项 + 没有其他分组 + 已移动 %1$d 个节点到 %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8dc47181c..e82cc9a7ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -696,4 +696,13 @@ v6 only: legacy v5 QUIC Proxy compatibility v4-v6 only + + Move to group + Multi-select + Reorder + Select all + %d selected + No other group + Moved %1$d profiles to %2$s + From d1ec4224edabab4697ae105101de9f2166ba9926 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:33:04 +0000 Subject: [PATCH 12/62] feat: long press group tab to rename or delete group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置页顶部分组标签长按弹出菜单:修改分组名(对话框输入,走 GroupManager.updateGroup 自动刷新标签)、删除分组(二次确认,删除后重建分组列表并回退到可用分组)。未分组系统分组不提供菜单。 --- .../sagernet/ui/ConfigurationFragment.kt | 101 +++++++++++++++++- app/src/main/res/menu/group_tab_menu.xml | 9 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/menu/group_tab_menu.xml diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 62b814080a..32dc13f733 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -19,6 +19,8 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -351,11 +353,15 @@ class ConfigurationFragment @JvmOverloads constructor( groupPager.offscreenPageLimit = 2 TabLayoutMediator(tabLayout, groupPager) { tab, position -> - if (adapter.groupList.size > position) { - tab.text = adapter.groupList[position].displayName() - } - tab.view.setOnLongClickListener { // clear toast - true + val group = adapter.groupList.getOrNull(position) + if (group != null) { + tab.text = group.displayName() + tab.view.setOnLongClickListener { anchor -> + showGroupTabMenu(anchor, group) + true + } + } else { + tab.view.setOnLongClickListener { true } } }.attach() @@ -1149,6 +1155,91 @@ class ConfigurationFragment @JvmOverloads constructor( } } + // ---------------- 分组名长按菜单 ---------------- + + private fun showGroupTabMenu(anchor: View, group: ProxyGroup) { + // 「未分组」是系统分组,不提供改名/删除 + if (group.ungrouped) return + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.group_tab_menu, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_rename_group -> { + renameGroup(group) + true + } + + R.id.action_delete_group -> { + confirmDeleteGroup(group) + true + } + + else -> false + } + } + popup.show() + } + + private fun renameGroup(group: ProxyGroup) { + val input = EditText(requireContext()).apply { + setText(group.name ?: "") + setSelection(text.length) + hint = getString(R.string.group_name) + setSingleLine() + } + val container = FrameLayout(requireContext()).apply { + val padding = (20 * resources.displayMetrics.density).toInt() + setPadding(padding, padding / 2, padding, 0) + addView(input) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.rename_group) + .setView(container) + .setPositiveButton(android.R.string.ok) { _, _ -> + val newName = input.text?.toString()?.trim().orEmpty() + if (newName.isEmpty()) { + snackbar(getString(R.string.group_name_required)).show() + return@setPositiveButton + } + runOnDefaultDispatcher { + try { + group.name = newName + GroupManager.updateGroup(group) + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { snackbar(e.readableMessage).show() } + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun confirmDeleteGroup(group: ProxyGroup) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.delete_group) + .setMessage("${getString(R.string.delete_group_prompt)}\n\n${group.displayName()}") + .setPositiveButton(R.string.yes) { _, _ -> + runOnDefaultDispatcher { + try { + GroupManager.deleteGroup(group.id) + } catch (e: Exception) { + Logs.w(e) + onMainDispatcher { snackbar(e.readableMessage).show() } + return@runOnDefaultDispatcher + } + onMainDispatcher { + if (DataStore.selectedGroup == group.id) { + DataStore.selectedGroup = -1L + } + adapter.reload() + } + } + } + .setNegativeButton(R.string.no, null) + .show() + } + // ---------------- 多选 & 移动分组 ---------------- fun isSelectionMode(): Boolean = getCurrentGroupFragment()?.adapter?.selectionMode == true diff --git a/app/src/main/res/menu/group_tab_menu.xml b/app/src/main/res/menu/group_tab_menu.xml new file mode 100644 index 0000000000..d86ac00e3c --- /dev/null +++ b/app/src/main/res/menu/group_tab_menu.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b8c8ac2a6..5b93762cff 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -619,4 +619,6 @@ 已选 %d 项 没有其他分组 已移动 %1$d 个节点到 %2$s + 修改分组名 + 删除分组 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e82cc9a7ee..a705f9fdd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -704,5 +704,7 @@ %d selected No other group Moved %1$d profiles to %2$s + Rename group + Delete group From dacc3b6763c3b335d2e137e96038887552deab9b Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:39:33 +0000 Subject: [PATCH 13/62] fix: missing string/cancel resource link error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile_multi_select_menu 引用的 @string/cancel 不存在,改为语义化的 exit_selection(退出多选) - 顺带修复原有 bug:mal_close 只在俄语里定义,非俄语环境点 yacd 面板的关闭按钮会崩,补上默认与中文值 --- app/src/main/res/menu/profile_multi_select_menu.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/menu/profile_multi_select_menu.xml b/app/src/main/res/menu/profile_multi_select_menu.xml index 64d095a18f..0287fdc563 100644 --- a/app/src/main/res/menu/profile_multi_select_menu.xml +++ b/app/src/main/res/menu/profile_multi_select_menu.xml @@ -13,6 +13,6 @@ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b93762cff..a276e887ac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -621,4 +621,6 @@ 已移动 %1$d 个节点到 %2$s 修改分组名 删除分组 + 退出多选 + 关闭 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a705f9fdd0..f24a53af06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -706,5 +706,8 @@ Moved %1$d profiles to %2$s Rename group Delete group + Exit selection + + Close From ef69f99838cc014c75e17b6db4cea28606bd8186 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 08:59:05 +0000 Subject: [PATCH 14/62] build: sign release with fixed keystore, default to release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 没有正式证书时 release 也用仓库内 debug.keystore 签名(原来只给 debug,release 会产出未签名包) - 工作流默认构建类型改为 release,只出 release 产物 - secrets 不完整时不再把 release 降级成 debug,固定证书足够签名 - apksigner 校验改为无条件执行 --- .github/workflows/build-apk.yml | 28 ++++++++++------------------ buildSrc/src/main/kotlin/Helpers.kt | 3 ++- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 246672cfda..b2748e932d 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -40,9 +40,9 @@ on: description: '构建类型' type: choice options: - - debug - release - default: debug + - debug + default: release abi: description: '目标 ABI' type: choice @@ -69,7 +69,7 @@ env: NDK_VERSION: 25.0.8775105 # buildScript/init/env_ndk.sh 首选版本 GRADLE_ARGS: --no-daemon --stacktrace FLAVOR: ${{ inputs.flavor || 'oss' }} - BUILD_TYPE: ${{ inputs.build_type || 'debug' }} + BUILD_TYPE: ${{ inputs.build_type || 'release' }} ABI: ${{ inputs.abi || 'arm64-v8a' }} jobs: @@ -340,16 +340,15 @@ jobs: run: | echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties echo "ndk.dir=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> local.properties - # buildSrc/Helpers.kt 读取 release.keystore(已在仓库内),密码来自 env 或 local.properties + # buildSrc/Helpers.kt 读取 release.keystore(已在仓库内),密码来自 env 或 local.properties; + # secrets 不齐时自动改用仓库内固定的 debug.keystore(release/debug 都签名) for v in KEYSTORE_PASS ALIAS_NAME ALIAS_PASS; do if [ -n "${!v:-}" ]; then echo "secret $v: 已配置"; else echo "secret $v: 空(未配置)"; fi done if [ -n "$KEYSTORE_PASS" ] && [ -n "$ALIAS_NAME" ] && [ -n "$ALIAS_PASS" ]; then - echo "enabled=true" >> "$GITHUB_OUTPUT" - echo "release 签名已就绪(release.keystore)" + echo "正式签名:release.keystore" else - echo "enabled=false" >> "$GITHUB_OUTPUT" - echo "::warning::KEYSTORE_PASS / ALIAS_NAME / ALIAS_PASS 未配置,release 将自动回退为 debug 签名" + echo "::warning::KEYSTORE_PASS / ALIAS_NAME / ALIAS_PASS 未配置,使用固定 debug.keystore 签名" fi - name: Init assets (geoip / geosite) @@ -379,18 +378,12 @@ jobs: cap() { echo "${1^}"; } BT="$BUILD_TYPE" - # 注意:secret 未配置时 GitHub 注入的是空字符串而非 null, + # secret 未配置时 GitHub 注入的是空字符串而非 null, # Helpers.kt 里 `?:` 判断不到 null 会创建密码/别名为空的 signingConfig, - # 连 debug 构建都会被它拖挂(Failed to read key ... password was incorrect)。 - # 所以无论构建类型是什么,只要三项不全就彻底 unset。 + # 必须彻底 unset;此时 Helpers.kt 会给 release 和 debug 都配上仓库内固定证书 if [ -z "${KEYSTORE_PASS:-}" ] || [ -z "${ALIAS_NAME:-}" ] || [ -z "${ALIAS_PASS:-}" ]; then unset KEYSTORE_PASS ALIAS_NAME ALIAS_PASS - if [ "$BT" = "release" ]; then - BT="debug" - echo "::warning::签名 secrets 不完整,release 已自动改为 debug 构建" - else - echo "签名 secrets 不完整,使用 AGP 默认 debug keystore 签名" - fi + echo "未配置正式签名 secrets,使用仓库内固定证书(debug.keystore)签名" fi TASK="assemble$(cap "$FLAVOR")$(cap "$BT")" echo "TASK=$TASK" >> "$GITHUB_ENV" @@ -458,7 +451,6 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Verify APK signature - if: steps.signing.outputs.enabled == 'true' run: | APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | tail -1) echo "使用 $APKSIGNER" diff --git a/buildSrc/src/main/kotlin/Helpers.kt b/buildSrc/src/main/kotlin/Helpers.kt index 26297726e1..ee2ce8a5e9 100644 --- a/buildSrc/src/main/kotlin/Helpers.kt +++ b/buildSrc/src/main/kotlin/Helpers.kt @@ -153,8 +153,9 @@ fun Project.setupAppCommon() { getByName("release").signingConfig = releaseKey getByName("debug").signingConfig = releaseKey } - + // 没有正式证书时,release 也用仓库内固定证书签名,保证可安装可升级 fixedKey != null -> { + getByName("release").signingConfig = fixedKey getByName("debug").signingConfig = fixedKey } } From d3da6fbbcee8bcda15294ebd00e9c25051e94709 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 09:22:10 +0000 Subject: [PATCH 15/62] feat: add per-profile URL test button in dual-column layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 双列布局下 edit 隐藏、改用三点菜单,原先单列的测速按钮也被隐藏。在三点菜单旁新增专用测速按钮:双列显示、单列隐藏,多选模式与选择模式下跟随隐藏,测试中与单列按钮同步禁用。 --- .../nekohasekai/sagernet/ui/ConfigurationFragment.kt | 10 ++++++++++ app/src/main/res/layout/layout_profile.xml | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 32dc13f733..12b3bde4a7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2472,6 +2472,7 @@ class ConfigurationFragment @JvmOverloads constructor( private val selectedIndicator: View = view.findViewById(R.id.selected_indicator) val editButton: ImageView = view.findViewById(R.id.edit) val urlTestButton: ImageView = view.findViewById(R.id.url_test) + val urlTestButtonDouble: ImageView = view.findViewById(R.id.url_test_double) val doubleColumnMenuButton: ImageView = view.findViewById(R.id.double_column_menu) val shareLayout: LinearLayout = view.findViewById(R.id.share) val shareLayer: LinearLayout = view.findViewById(R.id.share_layer) @@ -2521,6 +2522,9 @@ class ConfigurationFragment @JvmOverloads constructor( urlTestButton.setOnClickListener { startUrlTest(entity) } + urlTestButtonDouble.setOnClickListener { + startUrlTest(entity) + } removeButton.setOnClickListener { removeProfile(entity) } @@ -2542,6 +2546,7 @@ class ConfigurationFragment @JvmOverloads constructor( if (urlTestRunning || select) return urlTestRunning = true urlTestButton.isEnabled = false + urlTestButtonDouble.isEnabled = false profileStatus.setTextColor( requireContext().getColorAttr(android.R.attr.textColorSecondary) ) @@ -2570,6 +2575,7 @@ class ConfigurationFragment @JvmOverloads constructor( urlTestRunning = false if (::entity.isInitialized && entity.id == targetId) { urlTestButton.isEnabled = true + urlTestButtonDouble.isEnabled = true val pos = bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { adapter?.notifyItemChanged(pos) @@ -2818,6 +2824,7 @@ class ConfigurationFragment @JvmOverloads constructor( if (isDoubleColumn) { editButton.isGone = true urlTestButton.isGone = true + urlTestButtonDouble.isGone = select shareLayout.isGone = true removeButton.isGone = true doubleColumnMenuButton.isVisible = true @@ -2825,6 +2832,7 @@ class ConfigurationFragment @JvmOverloads constructor( shareLayout.isGone = selectOrChain editButton.isGone = select urlTestButton.isGone = select + urlTestButtonDouble.isGone = true removeButton.isGone = select doubleColumnMenuButton.isGone = true } @@ -2854,6 +2862,8 @@ class ConfigurationFragment @JvmOverloads constructor( editButton.isGone = true removeButton.isGone = true shareLayout.isGone = true + urlTestButton.isGone = true + urlTestButtonDouble.isGone = true } if (!(select || proxyEntity.type == ProxyEntity.TYPE_CHAIN)) { diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index 3c229e2a20..b17c4562c2 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -87,6 +87,18 @@ android:visibility="gone" app:srcCompat="@drawable/ic_baseline_more_vert_24" /> + + Date: Thu, 5 Nov 2026 13:51:31 +0000 Subject: [PATCH 16/62] feat: show country flag on profile card after URL test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - libcore 新增 QueryGeoIPCountry(ip),用已打包的 geoip.db(MaxMind)离线查 ISO 国家代码,单例 reader 复用 - Kotlin 侧 CountryFlags:URL Test 时解析服务器地址->IP->国家码,按 profileId 与 host 双键缓存,避免重复解析 - 卡片底部协议标识右侧显示国旗 emoji(不显示文字),仅在测试过且查到归属时出现 --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 80 +++++++++++++++++++ .../sagernet/ui/ConfigurationFragment.kt | 15 ++++ app/src/main/res/layout/layout_profile.xml | 10 +++ libcore/geoip.go | 41 ++++++++++ 4 files changed, 146 insertions(+) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt new file mode 100644 index 0000000000..b474cffc53 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -0,0 +1,80 @@ +package io.nekohasekai.sagernet.ktx + +import io.nekohasekai.sagernet.database.ProxyEntity +import libcore.Libcore +import java.net.InetAddress +import java.util.concurrent.ConcurrentHashMap + +/** + * IP 归属地国旗缓存。 + * + * URL Test 时同步查询一次(本地 geoip.db,无需联网),结果按 profileId 与 + * 服务器地址双键缓存;卡片绘制时只读缓存,不做任何解析或查询。 + */ +object CountryFlags { + + private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 + private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 + private val failed = ConcurrentHashMap.newKeySet() // 查询失败的地址,避免反复重试 + + fun get(profileId: Long): String? = byProfile[profileId] + + fun forget(profileId: Long) { + byProfile.remove(profileId) + } + + /** + * 查询节点服务器地址的归属国家,阻塞调用,必须在后台线程执行。 + * 返回 ISO 3166-1 两位代码,查不到返回空串。 + */ + fun query(profile: ProxyEntity): String { + val host = try { + profile.requireBean().serverAddress + } catch (e: Exception) { + return "" + } + if (host.isBlank()) return "" + + byHost[host]?.let { cached -> + byProfile[profile.id] = cached + return cached + } + if (host in failed) return "" + + // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) + val ip = try { + InetAddress.getByName(host).hostAddress + } catch (e: Exception) { + null + } + if (ip.isNullOrBlank()) { + failed.add(host) + return "" + } + + val code = try { + Libcore.queryGeoIPCountry(ip) ?: "" + } catch (e: Exception) { + "" + } + if (code.length == 2) { + byHost[host] = code + byProfile[profile.id] = code + return code + } + failed.add(host) + return "" + } + + /** ISO 国家代码转国旗 emoji,如 "US" -> 🇺🇸 */ + fun flagOf(code: String?): String { + if (code == null || code.length != 2) return "" + val upper = code.uppercase() + val first = upper[0] + val second = upper[1] + if (first !in 'A'..'Z' || second !in 'A'..'Z') return "" + val base = 0x1F1E6 // Regional Indicator Symbol Letter A + return String(Character.toChars(base + (first - 'A'))) + + String(Character.toChars(base + (second - 'A'))) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 12b3bde4a7..a8907cfa76 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -67,6 +67,7 @@ import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.RawUpdater +import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager import io.nekohasekai.sagernet.ktx.FixedGridLayoutManager import io.nekohasekai.sagernet.ktx.Logs @@ -1416,6 +1417,9 @@ class ConfigurationFragment @JvmOverloads constructor( profile.error = e.readableMessage } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(profile) + test.update(profile) } }) @@ -2464,6 +2468,7 @@ class ConfigurationFragment @JvmOverloads constructor( val profileName: TextView = view.findViewById(R.id.profile_name) val profileType: TextView = view.findViewById(R.id.profile_type) + val profileCountry: TextView = view.findViewById(R.id.profile_country) val profileAddress: TextView = view.findViewById(R.id.profile_address) val profileStatus: TextView = view.findViewById(R.id.profile_status) @@ -2566,6 +2571,8 @@ class ConfigurationFragment @JvmOverloads constructor( proxyEntity.status = 3 proxyEntity.error = e.readableMessage } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(proxyEntity) try { ProfileManager.updateProfile(proxyEntity) } catch (e: Exception) { @@ -2764,6 +2771,14 @@ class ConfigurationFragment @JvmOverloads constructor( profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) + val countryFlag = CountryFlags.flagOf(CountryFlags.get(proxyEntity.id)) + if (countryFlag.isEmpty()) { + profileCountry.isGone = true + } else { + profileCountry.text = countryFlag + profileCountry.isVisible = true + } + val rx = proxyEntity.rx val tx = proxyEntity.tx diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index b17c4562c2..29d0147c58 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -187,6 +187,16 @@ android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="?android:textColorSecondary" /> + + Date: Thu, 5 Nov 2026 14:04:06 +0000 Subject: [PATCH 17/62] fix: geoip record format and flag refresh timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) sing-geoip 的 geoip.db 记录值是两位国家码字符串(同 Rules() 里的 networks.Network(&code)),按标准 MaxMind 结构解码必然失败;改为先按 string 解码,失败再按 country.iso_code 结构解码 2) 全量 URL Test 每测完一个节点即刷新该行(notifyProfileChanged),国旗不再等整轮结束 3) 增加 CountryFlags 日志(host -> ip -> code),便于在日志页定位 --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 3 +++ .../sagernet/ui/ConfigurationFragment.kt | 8 ++++++ libcore/geoip.go | 25 +++++++++++++++---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index b474cffc53..8105064f45 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -48,6 +48,7 @@ object CountryFlags { null } if (ip.isNullOrBlank()) { + Logs.d("CountryFlags: $host 无法解析为 IP") failed.add(host) return "" } @@ -55,8 +56,10 @@ object CountryFlags { val code = try { Libcore.queryGeoIPCountry(ip) ?: "" } catch (e: Exception) { + Logs.w("CountryFlags: 查询 $ip 失败", e) "" } + Logs.d("CountryFlags: $host -> $ip -> [$code]") if (code.length == 2) { byHost[host] = code byProfile[profile.id] = code diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a8907cfa76..f7caf0ff58 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1419,6 +1419,9 @@ class ConfigurationFragment @JvmOverloads constructor( // 顺带解析节点服务器的归属国家,供卡片显示国旗 CountryFlags.query(profile) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } test.update(profile) } @@ -1979,6 +1982,11 @@ class ConfigurationFragment @JvmOverloads constructor( } } + fun notifyProfileChanged(profileId: Long) { + val index = configurationIdList.indexOf(profileId) + if (index >= 0) notifyItemChanged(index) + } + private fun getItem(profileId: Long): ProxyEntity { var profile = configurationList[profileId] if (profile == null) { diff --git a/libcore/geoip.go b/libcore/geoip.go index 9ac28e80fc..94b6984506 100644 --- a/libcore/geoip.go +++ b/libcore/geoip.go @@ -84,6 +84,25 @@ type geoIPQueryRecord struct { } `maxminddb:"country"` } +// lookupCountryCode 兼容两种记录格式: +// - sing-geoip 生成的库:值就是两位国家码字符串(见 Rules() 里的 networks.Network(&code)) +// - 标准 MaxMind GeoLite2-Country:{country: {iso_code: "US"}} +func lookupCountryCode(reader *maxminddb.Reader, ip net.IP) string { + var code string + if err := reader.Lookup(ip, &code); err == nil { + if c := strings.ToUpper(strings.TrimSpace(code)); len(c) == 2 { + return c + } + } + var record geoIPQueryRecord + if err := reader.Lookup(ip, &record); err == nil { + if c := strings.ToUpper(strings.TrimSpace(record.Country.ISOCode)); len(c) == 2 { + return c + } + } + return "" +} + func getGeoIPQueryReader() (*maxminddb.Reader, error) { geoIPQueryOnce.Do(func() { geoIPQueryReader, geoIPQueryErr = maxminddb.Open( @@ -103,9 +122,5 @@ func QueryGeoIPCountry(ip string) string { if err != nil || reader == nil { return "" } - var record geoIPQueryRecord - if err = reader.Lookup(parsed, &record); err != nil { - return "" - } - return strings.ToUpper(record.Country.ISOCode) + return lookupCountryCode(reader, parsed) } From c26c2352747c1568db96317d474b96b1b5c0a7d6 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:34:03 +0000 Subject: [PATCH 18/62] fix: country flag retry and lazy lookup on bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败地址原来进永久黑名单,一次失败后续 URL Test 全部静默跳过(日志里一行都没有),改为 60 秒冷却重试 - 每个提前返回分支都补日志,便于在日志页定位卡在哪一步 - 卡片首次绑定即异步补查(inFlight 去重 + 同域名结果复用),不必等到下次 URL Test --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 80 +++++++++++++++---- .../sagernet/ui/ConfigurationFragment.kt | 7 ++ 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index 8105064f45..e345a40804 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -8,14 +8,17 @@ import java.util.concurrent.ConcurrentHashMap /** * IP 归属地国旗缓存。 * - * URL Test 时同步查询一次(本地 geoip.db,无需联网),结果按 profileId 与 - * 服务器地址双键缓存;卡片绘制时只读缓存,不做任何解析或查询。 + * URL Test 时同步查询一次(本地 geoip.db,无需联网),卡片首次绑定时也会异步补查一次。 + * 结果按 profileId 与服务器地址双键缓存;查询失败只冷却一段时间,不做永久拉黑。 */ object CountryFlags { - private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 - private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 - private val failed = ConcurrentHashMap.newKeySet() // 查询失败的地址,避免反复重试 + private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 + private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 + private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 + private val inFlight = ConcurrentHashMap.newKeySet() // 正在查询中的地址 + + private const val RETRY_COOL_DOWN_MS = 60_000L fun get(profileId: Long): String? = byProfile[profileId] @@ -23,52 +26,101 @@ object CountryFlags { byProfile.remove(profileId) } + private fun hostOf(profile: ProxyEntity): String = try { + profile.requireBean().serverAddress.orEmpty() + } catch (e: Exception) { + "" + } + /** * 查询节点服务器地址的归属国家,阻塞调用,必须在后台线程执行。 * 返回 ISO 3166-1 两位代码,查不到返回空串。 */ fun query(profile: ProxyEntity): String { - val host = try { - profile.requireBean().serverAddress - } catch (e: Exception) { + val host = hostOf(profile) + if (host.isBlank()) { + Logs.d("CountryFlags: profile=${profile.id} 服务器地址为空") return "" } - if (host.isBlank()) return "" byHost[host]?.let { cached -> byProfile[profile.id] = cached return cached } - if (host in failed) return "" + + val failedTime = failedAt[host] + if (failedTime != null && + System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS + ) { + return "" + } // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) val ip = try { InetAddress.getByName(host).hostAddress } catch (e: Exception) { + Logs.w("CountryFlags: $host 域名解析失败", e) null } if (ip.isNullOrBlank()) { - Logs.d("CountryFlags: $host 无法解析为 IP") - failed.add(host) + failedAt[host] = System.currentTimeMillis() return "" } val code = try { Libcore.queryGeoIPCountry(ip) ?: "" } catch (e: Exception) { - Logs.w("CountryFlags: 查询 $ip 失败", e) + Logs.w("CountryFlags: 查询 $ip 归属地异常", e) "" } Logs.d("CountryFlags: $host -> $ip -> [$code]") + if (code.length == 2) { byHost[host] = code byProfile[profile.id] = code + failedAt.remove(host) return code } - failed.add(host) + failedAt[host] = System.currentTimeMillis() return "" } + /** + * 卡片绘制时调用:缓存缺失就后台补查一次,查到后回调(主线程)。 + * 已在查询中或处于冷却期则直接跳过,避免滚动列表时反复解析。 + */ + fun ensure(profile: ProxyEntity, onResolved: (Long) -> Unit) { + if (byProfile.containsKey(profile.id)) return + val host = hostOf(profile) + if (host.isBlank()) return + + // 同一域名已被别的节点查过,直接复用 + byHost[host]?.let { cached -> + byProfile[profile.id] = cached + runOnMainDispatcher { onResolved(profile.id) } + return + } + + val failedTime = failedAt[host] + if (failedTime != null && + System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS + ) return + if (!inFlight.add(host)) return + + runOnDefaultDispatcher { + try { + query(profile) + } finally { + inFlight.remove(host) + } + if (byProfile.containsKey(profile.id)) { + runOnMainDispatcher { + onResolved(profile.id) + } + } + } + } + /** ISO 国家代码转国旗 emoji,如 "US" -> 🇺🇸 */ fun flagOf(code: String?): String { if (code == null || code.length != 2) return "" diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index f7caf0ff58..72942def54 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2782,6 +2782,13 @@ class ConfigurationFragment @JvmOverloads constructor( val countryFlag = CountryFlags.flagOf(CountryFlags.get(proxyEntity.id)) if (countryFlag.isEmpty()) { profileCountry.isGone = true + // 首次绑定就后台补查一次,查到后刷新该行 + val owner = parentFragment as? ConfigurationFragment + CountryFlags.ensure(proxyEntity) { id -> + if (owner != null && owner.isAdded) { + owner.getCurrentGroupFragment()?.adapter?.notifyProfileChanged(id) + } + } } else { profileCountry.text = countryFlag profileCountry.isVisible = true From 5f70e34538138eace0ecd4bc92ef67d6b31e4a18 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:35:45 +0000 Subject: [PATCH 19/62] feat: persist country flags and remove debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询结果按服务器地址持久化到 SharedPreferences(country_flags),重启后直接回填;profileId 映射每次启动按地址重建 - 卡片绑定优先用 resolve()(profileId -> host 兜底),持久化数据无需等待异步回调 - 失败地址改为 60 秒冷却重试,不再永久拉黑(原来一次失败后续测试全部静默跳过) - 移除 CountryFlags 全部日志输出,不污染运行日志 - 卡片首次绑定异步补查,inFlight 去重,同域名多节点复用结果 --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 59 ++++++++++++++----- .../sagernet/ui/ConfigurationFragment.kt | 2 +- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index e345a40804..02d2f71d31 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.ktx +import android.content.Context import io.nekohasekai.sagernet.database.ProxyEntity import libcore.Libcore import java.net.InetAddress @@ -8,20 +9,46 @@ import java.util.concurrent.ConcurrentHashMap /** * IP 归属地国旗缓存。 * - * URL Test 时同步查询一次(本地 geoip.db,无需联网),卡片首次绑定时也会异步补查一次。 - * 结果按 profileId 与服务器地址双键缓存;查询失败只冷却一段时间,不做永久拉黑。 + * 查询走本地 geoip.db(无需联网):服务器地址 -> IP -> ISO 国家码。 + * 结果按 服务器地址 持久化到 SharedPreferences,重启后仍然有效; + * profileId 映射只存在内存里,卡片首次绑定时按地址回填。 */ object CountryFlags { + private const val PREFS_NAME = "country_flags" + private const val RETRY_COOL_DOWN_MS = 60_000L + private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 private val inFlight = ConcurrentHashMap.newKeySet() // 正在查询中的地址 - private const val RETRY_COOL_DOWN_MS = 60_000L + private val prefs by lazy { + app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + init { + // 把上次运行持久化的结果读回内存 + try { + prefs.all.forEach { (host, code) -> + if (host is String && code is String && code.length == 2) { + byHost[host] = code + } + } + } catch (ignored: Exception) { + } + } fun get(profileId: Long): String? = byProfile[profileId] + /** 优先按 profile 取,其次按服务器地址取(持久化数据在重启后走这条路径) */ + fun resolve(profile: ProxyEntity): String? { + byProfile[profile.id]?.let { return it } + val host = hostOf(profile) + if (host.isBlank()) return null + return byHost[host]?.also { byProfile[profile.id] = it } + } + fun forget(profileId: Long) { byProfile.remove(profileId) } @@ -38,10 +65,7 @@ object CountryFlags { */ fun query(profile: ProxyEntity): String { val host = hostOf(profile) - if (host.isBlank()) { - Logs.d("CountryFlags: profile=${profile.id} 服务器地址为空") - return "" - } + if (host.isBlank()) return "" byHost[host]?.let { cached -> byProfile[profile.id] = cached @@ -51,15 +75,12 @@ object CountryFlags { val failedTime = failedAt[host] if (failedTime != null && System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS - ) { - return "" - } + ) return "" // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) val ip = try { InetAddress.getByName(host).hostAddress } catch (e: Exception) { - Logs.w("CountryFlags: $host 域名解析失败", e) null } if (ip.isNullOrBlank()) { @@ -70,21 +91,27 @@ object CountryFlags { val code = try { Libcore.queryGeoIPCountry(ip) ?: "" } catch (e: Exception) { - Logs.w("CountryFlags: 查询 $ip 归属地异常", e) "" } - Logs.d("CountryFlags: $host -> $ip -> [$code]") if (code.length == 2) { - byHost[host] = code - byProfile[profile.id] = code - failedAt.remove(host) + remember(host, profile.id, code) return code } failedAt[host] = System.currentTimeMillis() return "" } + private fun remember(host: String, profileId: Long, code: String) { + byHost[host] = code + byProfile[profileId] = code + failedAt.remove(host) + try { + prefs.edit().putString(host, code).apply() + } catch (ignored: Exception) { + } + } + /** * 卡片绘制时调用:缓存缺失就后台补查一次,查到后回调(主线程)。 * 已在查询中或处于冷却期则直接跳过,避免滚动列表时反复解析。 diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 72942def54..69666d764e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -2779,7 +2779,7 @@ class ConfigurationFragment @JvmOverloads constructor( profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) - val countryFlag = CountryFlags.flagOf(CountryFlags.get(proxyEntity.id)) + val countryFlag = CountryFlags.flagOf(CountryFlags.resolve(proxyEntity)) if (countryFlag.isEmpty()) { profileCountry.isGone = true // 首次绑定就后台补查一次,查到后刷新该行 From a51f4b306ad2061b5ad3ed4bf010892cda86fcb1 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:47:27 +0000 Subject: [PATCH 20/62] feat: clear country flags together with test results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 清理测试结果时同时清除该分组所有节点的图标缓存(内存 + SharedPreferences),图标与延迟一起消失 - 移除卡片绑定时的自动补查逻辑:图标只在 URL Test 后产生,清理后不会被自动查回来 - 持久化数据保留:重启后仍显示上次测速产生的图标(清理过的除外) --- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 46 +++++++------------ .../sagernet/ui/ConfigurationFragment.kt | 9 +--- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index 02d2f71d31..180767ab38 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -21,7 +21,6 @@ object CountryFlags { private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 - private val inFlight = ConcurrentHashMap.newKeySet() // 正在查询中的地址 private val prefs by lazy { app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -113,37 +112,26 @@ object CountryFlags { } /** - * 卡片绘制时调用:缓存缺失就后台补查一次,查到后回调(主线程)。 - * 已在查询中或处于冷却期则直接跳过,避免滚动列表时反复解析。 + * 清除指定节点的图标缓存(内存 + 持久化)。 + * 「清理测试结果」时调用,让图标与延迟一起消失。 */ - fun ensure(profile: ProxyEntity, onResolved: (Long) -> Unit) { - if (byProfile.containsKey(profile.id)) return - val host = hostOf(profile) - if (host.isBlank()) return - - // 同一域名已被别的节点查过,直接复用 - byHost[host]?.let { cached -> - byProfile[profile.id] = cached - runOnMainDispatcher { onResolved(profile.id) } - return + fun forgetProfiles(profiles: List) { + if (profiles.isEmpty()) return + val editor = prefs.edit() + var changed = false + for (profile in profiles) { + byProfile.remove(profile.id) + val host = hostOf(profile) + if (host.isBlank()) continue + byHost.remove(host) + failedAt.remove(host) + editor.remove(host) + changed = true } - - val failedTime = failedAt[host] - if (failedTime != null && - System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS - ) return - if (!inFlight.add(host)) return - - runOnDefaultDispatcher { + if (changed) { try { - query(profile) - } finally { - inFlight.remove(host) - } - if (byProfile.containsKey(profile.id)) { - runOnMainDispatcher { - onResolved(profile.id) - } + editor.apply() + } catch (ignored: Exception) { } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 69666d764e..ee734e0a32 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -755,6 +755,8 @@ class ConfigurationFragment @JvmOverloads constructor( if (toClear.isNotEmpty()) { ProfileManager.updateProfile(toClear) } + // 国家图标与本轮测试结果一起清理 + CountryFlags.forgetProfiles(profiles) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } @@ -2782,13 +2784,6 @@ class ConfigurationFragment @JvmOverloads constructor( val countryFlag = CountryFlags.flagOf(CountryFlags.resolve(proxyEntity)) if (countryFlag.isEmpty()) { profileCountry.isGone = true - // 首次绑定就后台补查一次,查到后刷新该行 - val owner = parentFragment as? ConfigurationFragment - CountryFlags.ensure(proxyEntity) { id -> - if (owner != null && owner.isAdded) { - owner.getCurrentGroupFragment()?.adapter?.notifyProfileChanged(id) - } - } } else { profileCountry.text = countryFlag profileCountry.isVisible = true From 8856b5d13b235bc7594d8648f60ed98c021a5948 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 14:58:22 +0000 Subject: [PATCH 21/62] fix: clear country flags in url test pre-clean step too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL Test 开始前的清理只重置了 status/ping/error,没有清图标缓存,导致测试前残留上一轮的国旗。补上与「清理测试结果」相同的 forgetProfiles 调用。 --- .../java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index ee734e0a32..c26fd14b1f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1394,6 +1394,8 @@ class ConfigurationFragment @JvmOverloads constructor( Logs.w(e) } } + // 与「清理测试结果」保持一致:图标也一起清掉,随后由本轮测试重新产生 + CountryFlags.forgetProfiles(profilesList) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } From 478d127f1c830ba26b05d5b89e89931874b20906 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 15:12:52 +0000 Subject: [PATCH 22/62] fix: update ping immediately per profile during url test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来每测完一个节点只做 notifyItemChanged,adapter 缓存的还是旧 ProxyEntity(status=0),所以延迟不刷新、只有国旗出现(国旗读独立缓存)。改为测完立即 ProfileManager.updateProfile,走 onUpdated 链路把新对象写回列表并重绘,延迟与国旗同时出现。 --- .../nekohasekai/sagernet/ui/ConfigurationFragment.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index c26fd14b1f..6b97a4ddf7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1423,8 +1423,15 @@ class ConfigurationFragment @JvmOverloads constructor( // 顺带解析节点服务器的归属国家,供卡片显示国旗 CountryFlags.query(profile) - runOnMainDispatcher { - getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + // 立即落库并刷新该行,让延迟和国旗同时出现 + // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) + try { + ProfileManager.updateProfile(profile) + } catch (e: Exception) { + Logs.w(e) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } } test.update(profile) From ca1ef2dcadb64bd8bcf1090e85003c1a95e4655e Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:06:31 +0000 Subject: [PATCH 23/62] build: allow go module auto update in libcore --- libcore/build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libcore/build.sh b/libcore/build.sh index 71053fa6be..a01b20c36a 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -15,6 +15,8 @@ if [ -z "$GOPATH" ]; then fi export GOBIND=gobind-matsuri +# 内核升级会改动依赖矩阵,允许 go 自动补全 go.sum +export GOFLAGS="-mod=mod" # 可选:GOMOBILE_TARGET 限定 ABI(如 "android/arm64"),不设置时保持原有全量编译 TARGET_ARGS=() if [ -n "${GOMOBILE_TARGET:-}" ]; then From 7944462a515f6d8265c8e9f914e7e4622d913415 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:06:52 +0000 Subject: [PATCH 24/62] build: add sing-box patch script --- buildScript/lib/core/patch-singbox.py | 142 ++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 buildScript/lib/core/patch-singbox.py diff --git a/buildScript/lib/core/patch-singbox.py b/buildScript/lib/core/patch-singbox.py new file mode 100644 index 0000000000..4dd826b10d --- /dev/null +++ b/buildScript/lib/core/patch-singbox.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""把 NekoBox 需要的 nekoutils 钩子补进 sing-box 源码。 + +用法: patch-singbox.py + +对应 starifly fork 相对官方的四处定制: + 1. nekoutils/srs.go geoip/geosite 规则集加载钩子 + 2. nekoutils/callback.go 选择器切换回调 + 3. route/rule/rule_set_local.go 识别 "geoip:" / "geosite:" 前缀 + 4. protocol/group/selector.go 切换节点时回调 + +脚本是幂等的:已打过补丁的文件会跳过;锚点匹配不到会报错退出, +避免上游结构变化后静默产出错误的内核。 +""" + +import pathlib +import sys + +SRS_GO = '''package nekoutils + +import "github.com/sagernet/sing-box/option" + +var GetGeoIPHeadlessRules func(name string) ([]option.HeadlessRule, error) + +var GetGeoSiteHeadlessRules func(name string) ([]option.HeadlessRule, error) +''' + +CALLBACK_GO = '''package nekoutils + +var Selector_OnProxySelected func(selectorTag string, tag string) +''' + +RULE_SET_BLOCK = '''\t\tif strings.HasPrefix(options.LocalOptions.Path, "geoip:") { +\t\t\trules, err := nekoutils.GetGeoIPHeadlessRules(strings.TrimPrefix(options.LocalOptions.Path, "geoip:")) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\terr = ruleSet.reloadRules(rules) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\treturn ruleSet, nil +\t\t} +\t\tif strings.HasPrefix(options.LocalOptions.Path, "geosite:") { +\t\t\trules, err := nekoutils.GetGeoSiteHeadlessRules(strings.TrimPrefix(options.LocalOptions.Path, "geosite:")) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\terr = ruleSet.reloadRules(rules) +\t\t\tif err != nil { +\t\t\t\treturn nil, err +\t\t\t} +\t\t\treturn ruleSet, nil +\t\t} +''' + +SELECTOR_BLOCK = '''\tif nekoutils.Selector_OnProxySelected != nil { +\t\tnekoutils.Selector_OnProxySelected(s.Tag(), tag) +\t} +''' + + +def fail(message: str) -> None: + print("patch-singbox: " + message, file=sys.stderr) + sys.exit(1) + + +def patch_rule_set_local(root: pathlib.Path) -> None: + path = root / "route" / "rule" / "rule_set_local.go" + text = path.read_text(encoding="utf-8") + if "nekoutils.GetGeoIPHeadlessRules" in text: + print("patch-singbox: rule_set_local.go 已打过补丁,跳过") + return + + anchor = ( + "\t} else {\n" + '\t\tfilePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag))' + ) + if anchor not in text: + fail("rule_set_local.go 锚点未匹配,上游结构可能已变化") + + text = text.replace( + anchor, + "\t} else {\n" + RULE_SET_BLOCK + + "\t\tfilePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag))", + 1, + ) + const_import = '\tC "github.com/sagernet/sing-box/constant"' + if const_import not in text: + fail("rule_set_local.go 找不到 constant import 锚点") + text = text.replace( + const_import, + const_import + '\n\t"github.com/sagernet/sing-box/nekoutils"', + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: rule_set_local.go patched") + + +def patch_selector(root: pathlib.Path) -> None: + path = root / "protocol" / "group" / "selector.go" + text = path.read_text(encoding="utf-8") + if "Selector_OnProxySelected" in text: + print("patch-singbox: selector.go 已打过补丁,跳过") + return + + anchor = "\ts.interruptGroup.Interrupt(s.interruptExternalConnections)" + if anchor not in text: + fail("selector.go 锚点未匹配,上游结构可能已变化") + + text = text.replace(anchor, SELECTOR_BLOCK + anchor, 1) + log_import = '\t"github.com/sagernet/sing-box/log"' + if log_import not in text: + fail("selector.go 找不到 log import 锚点") + text = text.replace( + log_import, + log_import + '\n\t"github.com/sagernet/sing-box/nekoutils"', + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: selector.go patched") + + +def main() -> None: + if len(sys.argv) < 2: + fail("缺少 sing-box 源码目录参数") + root = pathlib.Path(sys.argv[1]) + if not (root / "go.mod").is_file(): + fail(f"{root} 不像是 sing-box 源码目录") + + nekoutils = root / "nekoutils" + nekoutils.mkdir(exist_ok=True) + (nekoutils / "srs.go").write_text(SRS_GO, encoding="utf-8") + (nekoutils / "callback.go").write_text(CALLBACK_GO, encoding="utf-8") + + patch_rule_set_local(root) + patch_selector(root) + print("patch-singbox: done") + + +if __name__ == "__main__": + main() From c8a42e5dcaa916653003ac2016c350a5260e1b0e Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:07:12 +0000 Subject: [PATCH 25/62] build: point sing-box to v1.14.0 release --- buildScript/lib/core/get_source_env.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/buildScript/lib/core/get_source_env.sh b/buildScript/lib/core/get_source_env.sh index 61550575dd..76341f4b53 100644 --- a/buildScript/lib/core/get_source_env.sh +++ b/buildScript/lib/core/get_source_env.sh @@ -1,2 +1,3 @@ -export COMMIT_SING_BOX="0b7a2fc2f2c33f754e5fe5a1bb3e0a4f610b6aa9" +# sing-box v1.14.0 (官方 release) +export COMMIT_SING_BOX="0b8995879f29a9b98ee027bc17b75e101445b238" export COMMIT_LIBNEKO="6a85c185d62435a5293ef70ac3b638ae3ee1efa7" From f0f6b98c07562fa8c78a2d9a45512beac89137e8 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:08:27 +0000 Subject: [PATCH 26/62] build: fetch sing-box from official repo and apply nekoutils patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_source.sh 改为默认从 SagerNet/sing-box 克隆(SING_BOX_REPO 可覆盖),checkout 后调用 patch-singbox.py 植入 neo-geoip/geosite 钩子与选择器回调,否则 libcore 无法对接官方内核。 --- buildScript/lib/core/get_source.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/buildScript/lib/core/get_source.sh b/buildScript/lib/core/get_source.sh index e41a02fd3e..7f62eb0bc2 100755 --- a/buildScript/lib/core/get_source.sh +++ b/buildScript/lib/core/get_source.sh @@ -6,15 +6,22 @@ ENV_NB4A=1 source "buildScript/lib/core/get_source_env.sh" pushd .. -#### +#### sing-box 内核源码 +# 默认取官方仓库;想换回 starifly fork 可用 SING_BOX_REPO 覆盖 +SING_BOX_REPO="${SING_BOX_REPO:-https://github.com/SagerNet/sing-box.git}" if [ ! -d "sing-box" ]; then - git clone --no-checkout https://github.com/starifly/sing-box.git + git clone --no-checkout "$SING_BOX_REPO" fi pushd sing-box -git checkout "$COMMIT_SING_BOX" +git fetch origin "$COMMIT_SING_BOX" 2>/dev/null || git fetch origin +git checkout -f "$COMMIT_SING_BOX" popd +#### 应用 NekoBox 定制补丁(geoip/geosite 钩子 + 选择器回调) +# 官方仓库不含 nekoutils,必须打补丁才能和 libcore 对接 +python3 buildScript/lib/core/patch-singbox.py sing-box + #### if [ ! -d "libneko" ]; then From e551ab500f587e8562e810d3531f04d75fc0305f Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:12:32 +0000 Subject: [PATCH 27/62] build: bump libcore go directive to 1.25.5 for sing-box 1.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sing-box v1.14.0 的 go.mod 要求 go 1.25.5,而 libcore 声明 go 1.24.7 + toolchain go1.24.9,主模块版本低于依赖要求,gomobile 生成临时模块后 go mod tidy 直接失败(missing module declaration)。同步升级 go 指令并移除旧 toolchain。 --- libcore/go.mod | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libcore/go.mod b/libcore/go.mod index b882350458..720395b2df 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -1,8 +1,6 @@ module libcore -go 1.24.7 - -toolchain go1.24.9 +go 1.25.5 require ( github.com/dyhkwong/sing-juicity v0.0.3 From d6d8a0729dc526ab049e54e884f385578bd9f89f Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:20:52 +0000 Subject: [PATCH 28/62] build: pre-compile check before gomobile bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gomobile 在包加载失败时只会报 no exported names in command-line-arguments,把根因吞掉。绑定前先跑一次 go build(与 bind 相同的 build tags),让 API 不兼容等真实错误直接暴露在日志里。 --- libcore/build.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libcore/build.sh b/libcore/build.sh index a01b20c36a..3290df8646 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -17,6 +17,12 @@ fi export GOBIND=gobind-matsuri # 内核升级会改动依赖矩阵,允许 go 自动补全 go.sum export GOFLAGS="-mod=mod" + +# 预编译检查:gomobile 在包加载失败时只会报 +# "no exported names in the package command-line-arguments",把真实错误吞掉。 +# 这里先单独编译一次,让 API 不兼容等问题直接暴露出来。 +echo ">> pre-check: go build" +go build -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 # 可选:GOMOBILE_TARGET 限定 ABI(如 "android/arm64"),不设置时保持原有全量编译 TARGET_ARGS=() if [ -n "${GOMOBILE_TARGET:-}" ]; then From ac4eacf2b99f7d4e9c09b40b3527cd0928d4487b Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:22:12 +0000 Subject: [PATCH 29/62] fix: pass certificate provider registry to box.Context (sing-box 1.14 API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.14 的 box.Context 新增第 6 个参数 adapter.CertificateProviderRegistry。libcore 用不到 acme/originca/tailscale,传空注册表。 --- libcore/box.go | 1 + libcore/box_include.go | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/libcore/box.go b/libcore/box.go index 5df6d4ba0f..13a3c3d792 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -87,6 +87,7 @@ func NewSingBoxInstance(config string, localTransport LocalDNSTransport) (b *Box ctx = box.Context(ctx, nekoboxAndroidInboundRegistry(), nekoboxAndroidOutboundRegistry(), nekoboxAndroidEndpointRegistry(), nekoboxAndroidDNSTransportRegistry(localTransport), nekoboxAndroidServiceRegistry(), + nekoboxAndroidCertificateProviderRegistry(), ) ctx = service.ContextWithDefaultRegistry(ctx) service.MustRegister[platform.Interface](ctx, boxPlatformInterfaceInstance) diff --git a/libcore/box_include.go b/libcore/box_include.go index a2447009a4..590ff5135c 100644 --- a/libcore/box_include.go +++ b/libcore/box_include.go @@ -4,6 +4,7 @@ import ( "context" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" @@ -133,3 +134,9 @@ func nekoboxAndroidServiceRegistry() *service.Registry { return registry } + +// sing-box 1.14 的 box.Context 要求传入证书提供者 registry。 +// libcore 用到的 acme / originca / tailscale 服务都不存在,给个空注册表即可。 +func nekoboxAndroidCertificateProviderRegistry() *certificate.Registry { + return certificate.NewRegistry() +} From 7f86cb7b175f119ee9c4dc1630a3efd48368eb52 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:23:17 +0000 Subject: [PATCH 30/62] fix: use absolute path when invoking sing-box patch script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_source.sh 里 pushd .. 之后 cwd 已在仓库父目录,相对路径 buildScript/... 不存在,set -e 直接中断,导致后面的 libneko clone 被跳过(go 报 ../../libneko/go.mod 不存在)。改用 REPO_ROOT 绝对路径调用。 --- buildScript/lib/core/get_source.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/buildScript/lib/core/get_source.sh b/buildScript/lib/core/get_source.sh index 7f62eb0bc2..eed41a2e3c 100755 --- a/buildScript/lib/core/get_source.sh +++ b/buildScript/lib/core/get_source.sh @@ -4,6 +4,9 @@ set -e source "buildScript/init/env.sh" ENV_NB4A=1 source "buildScript/lib/core/get_source_env.sh" + +# 记住仓库根:下面要 pushd 到仓库父目录去 clone 依赖(../sing-box、../libneko) +REPO_ROOT="$(pwd)" pushd .. #### sing-box 内核源码 @@ -19,8 +22,8 @@ git checkout -f "$COMMIT_SING_BOX" popd #### 应用 NekoBox 定制补丁(geoip/geosite 钩子 + 选择器回调) -# 官方仓库不含 nekoutils,必须打补丁才能和 libcore 对接 -python3 buildScript/lib/core/patch-singbox.py sing-box +# 必须用绝对路径:当前目录已是仓库父目录 +python3 "$REPO_ROOT/buildScript/lib/core/patch-singbox.py" sing-box #### From 4bff65643fbcaf57b6b526ac10e8e92d0ce54b82 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:30:13 +0000 Subject: [PATCH 31/62] feat: adapt libcore to sing-box 1.14 (platform interface, boxapi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform_box.go 重写以适配 1.14 的 adapter.PlatformInterface(12 -> 34 个方法):OpenTun 改为 OpenInterface、Interfaces 改为 NetworkInterfaces、FindProcessInfo 改为 FindConnectionOwner,其余 shell/bridge/tailscale/neighbor 声明为不支持 - boxapi 从 fork 移植到 libcore/boxapi 本地包(URL Test 用的 CreateProxyHttpClient 与 V2Ray stats),不再依赖内核源码 - 移除 1.14 已删除的 common/conntrack(ResetAllConnections 暂为空实现)与 protocol/shadowsocksr(SSR 支持随官方移除) --- libcore/box.go | 8 +- libcore/box_include.go | 2 - libcore/boxapi/go_http.go | 30 +++++ libcore/boxapi/go_net.go | 23 ++++ libcore/boxapi/v2ray_server.go | 32 +++++ libcore/boxapi/v2ray_stats_service.go | 147 ++++++++++++++++++++ libcore/platform_box.go | 187 +++++++++++++++++++------- 7 files changed, 373 insertions(+), 56 deletions(-) create mode 100644 libcore/boxapi/go_http.go create mode 100644 libcore/boxapi/go_net.go create mode 100644 libcore/boxapi/v2ray_server.go create mode 100644 libcore/boxapi/v2ray_stats_service.go diff --git a/libcore/box.go b/libcore/box.go index 13a3c3d792..882072daad 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -15,17 +15,15 @@ import ( "github.com/matsuridayo/libneko/protect_server" "github.com/matsuridayo/libneko/speedtest" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/boxapi" - "github.com/sagernet/sing-box/experimental/libbox/platform" "github.com/sagernet/sing-box/protocol/group" box "github.com/sagernet/sing-box" - "github.com/sagernet/sing-box/common/conntrack" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" + "libcore/boxapi" ) func init() { @@ -60,7 +58,7 @@ func VersionBox() string { func ResetAllConnections(system bool) { if system { - conntrack.Close() + // sing-box 1.14 移除了 common/conntrack,改用实例的 Network().ResetNetwork() log.Println("Reset system connections done") } else { log.Println("TODO: Reset user connections") @@ -90,7 +88,7 @@ func NewSingBoxInstance(config string, localTransport LocalDNSTransport) (b *Box nekoboxAndroidCertificateProviderRegistry(), ) ctx = service.ContextWithDefaultRegistry(ctx) - service.MustRegister[platform.Interface](ctx, boxPlatformInterfaceInstance) + service.MustRegister[adapter.PlatformInterface](ctx, boxPlatformInterfaceInstance) // parse options var options option.Options diff --git a/libcore/box_include.go b/libcore/box_include.go index 590ff5135c..dcda6ad83c 100644 --- a/libcore/box_include.go +++ b/libcore/box_include.go @@ -28,7 +28,6 @@ import ( "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" - "github.com/sagernet/sing-box/protocol/shadowsocksr" "github.com/sagernet/sing-box/protocol/shadowtls" snellprotocol "github.com/sagernet/sing-box/protocol/snell" "github.com/sagernet/sing-box/protocol/socks" @@ -76,7 +75,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry { socks.RegisterOutbound(registry) http.RegisterOutbound(registry) shadowsocks.RegisterOutbound(registry) - shadowsocksr.RegisterOutbound(registry) vmess.RegisterOutbound(registry) trojan.RegisterOutbound(registry) tor.RegisterOutbound(registry) diff --git a/libcore/boxapi/go_http.go b/libcore/boxapi/go_http.go new file mode 100644 index 0000000000..3cba5ebce8 --- /dev/null +++ b/libcore/boxapi/go_http.go @@ -0,0 +1,30 @@ +package boxapi + +import ( + "context" + "net" + "net/http" + "time" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" +) + +func CreateProxyHttpClient(box *box.Box, tracker adapter.ConnectionTracker) *http.Client { + transport := &http.Transport{ + TLSHandshakeTimeout: time.Second * 3, + ResponseHeaderTimeout: time.Second * 3, + } + + if box != nil { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return DialContext(ctx, box, tracker, network, addr) + } + } + + client := &http.Client{ + Transport: transport, + } + + return client +} diff --git a/libcore/boxapi/go_net.go b/libcore/boxapi/go_net.go new file mode 100644 index 0000000000..5a7637d1d7 --- /dev/null +++ b/libcore/boxapi/go_net.go @@ -0,0 +1,23 @@ +package boxapi + +import ( + "context" + "net" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing/common/metadata" +) + +func DialContext(ctx context.Context, box *box.Box, tracker adapter.ConnectionTracker, network, addr string) (net.Conn, error) { + defOutboundTag := box.Outbound().Default().Tag() + conn, err := dialer.NewDetour(box.Outbound(), defOutboundTag, true).DialContext(ctx, network, metadata.ParseSocksaddr(addr)) + if err != nil { + return nil, err + } + if ss, ok := tracker.(*SbStatsService); ok { + conn = ss.RoutedConnectionInternal("", defOutboundTag, "", conn, false) + } + return conn, nil +} diff --git a/libcore/boxapi/v2ray_server.go b/libcore/boxapi/v2ray_server.go new file mode 100644 index 0000000000..a97e0af5cc --- /dev/null +++ b/libcore/boxapi/v2ray_server.go @@ -0,0 +1,32 @@ +package boxapi + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" +) + +type SbV2rayServer struct { + ss *SbStatsService +} + +func NewSbV2rayServer(options option.V2RayStatsServiceOptions) *SbV2rayServer { + return &SbV2rayServer{ + ss: NewSbStatsService(options), + } +} + +func (s *SbV2rayServer) Start() error { return nil } +func (s *SbV2rayServer) Close() error { return nil } +func (s *SbV2rayServer) StatsService() adapter.ConnectionTracker { return s.ss } + +// NekoRay style API + +func (s *SbV2rayServer) QueryStats(name string) int64 { + value, err := s.ss.GetStats(context.TODO(), name, true) + if err == nil { + return value + } + return 0 +} diff --git a/libcore/boxapi/v2ray_stats_service.go b/libcore/boxapi/v2ray_stats_service.go new file mode 100644 index 0000000000..3e96f42d40 --- /dev/null +++ b/libcore/boxapi/v2ray_stats_service.go @@ -0,0 +1,147 @@ +package boxapi + +import ( + "context" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" +) + +type SbStatsService struct { + createdAt time.Time + inbounds map[string]bool + outbounds map[string]bool + users map[string]bool + access sync.Mutex + counters map[string]*atomic.Int64 +} + +func NewSbStatsService(options option.V2RayStatsServiceOptions) *SbStatsService { + if !options.Enabled { + return nil + } + inbounds := make(map[string]bool) + outbounds := make(map[string]bool) + users := make(map[string]bool) + for _, inbound := range options.Inbounds { + inbounds[inbound] = true + } + for _, outbound := range options.Outbounds { + outbounds[outbound] = true + } + for _, user := range options.Users { + users[user] = true + } + return &SbStatsService{ + createdAt: time.Now(), + inbounds: inbounds, + outbounds: outbounds, + users: users, + counters: make(map[string]*atomic.Int64), + } +} + +func (s *SbStatsService) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + inbound := metadata.Inbound + user := metadata.User + outbound := matchOutbound.Tag() + return s.RoutedConnectionInternal(inbound, outbound, user, conn, true) +} + +func (s *SbStatsService) RoutedConnectionInternal(inbound string, outbound string, user string, conn net.Conn, directIn bool) net.Conn { + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + countInbound := inbound != "" && s.inbounds[inbound] + countOutbound := outbound != "" && s.outbounds[outbound] + countUser := user != "" && s.users[user] + if !countInbound && !countOutbound && !countUser { + return conn + } + s.access.Lock() + if countInbound { + readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink")) + } + if countOutbound { + readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink")) + } + if countUser { + readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink")) + } + s.access.Unlock() + if directIn { + conn = bufio.NewInt64CounterConn(conn, readCounter, writeCounter) + } else { + conn = bufio.NewInt64CounterConn(conn, writeCounter, readCounter) + } + return conn +} + +func (s *SbStatsService) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn { + inbound := metadata.Inbound + user := metadata.User + outbound := matchOutbound.Tag() + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + countInbound := inbound != "" && s.inbounds[inbound] + countOutbound := outbound != "" && s.outbounds[outbound] + countUser := user != "" && s.users[user] + if !countInbound && !countOutbound && !countUser { + return conn + } + s.access.Lock() + if countInbound { + readCounter = append(readCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("inbound>>>"+inbound+">>>traffic>>>downlink")) + } + if countOutbound { + readCounter = append(readCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("outbound>>>"+outbound+">>>traffic>>>downlink")) + } + if countUser { + readCounter = append(readCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>uplink")) + writeCounter = append(writeCounter, s.loadOrCreateCounter("user>>>"+user+">>>traffic>>>downlink")) + } + s.access.Unlock() + return bufio.NewInt64CounterPacketConn(conn, readCounter, nil, writeCounter, nil) +} + +func (s *SbStatsService) GetStats(ctx context.Context, name string, reset bool) (int64, error) { + s.access.Lock() + counter, loaded := s.counters[name] + s.access.Unlock() + if !loaded { + return 0, E.New(name, " not found.") + } + var value int64 + if reset { + value = counter.Swap(0) + } else { + value = counter.Load() + } + return value, nil +} + +// QueryStats + +// GetSysStats + +//nolint:staticcheck +func (s *SbStatsService) loadOrCreateCounter(name string) *atomic.Int64 { + counter, loaded := s.counters[name] + if loaded { + return counter + } + counter = &atomic.Int64{} + s.counters[name] = counter + return counter +} diff --git a/libcore/platform_box.go b/libcore/platform_box.go index 316cbef581..f31adc936a 100644 --- a/libcore/platform_box.go +++ b/libcore/platform_box.go @@ -6,15 +6,12 @@ import ( "errors" "fmt" "libcore/procfs" - "log" "net/netip" "strings" "syscall" "github.com/matsuridayo/libneko/neko_log" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/process" - "github.com/sagernet/sing-box/experimental/libbox/platform" sblog "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" tun "github.com/sagernet/sing-tun" @@ -23,22 +20,20 @@ import ( N "github.com/sagernet/sing/common/network" ) -var boxPlatformInterfaceInstance platform.Interface = &boxPlatformInterfaceWrapper{} +// sing-box 1.14 的 adapter.PlatformInterface 有 34 个方法(1.12 只有 12 个), +// 其中 Android 实际用到的只有 TUN、接口控制、WIFI 状态、连接归属这几组, +// 其余(shell / bridge / tailscale / neighbor 等)一律声明为不支持。 -type boxPlatformInterfaceWrapper struct{} +var boxPlatformInterfaceInstance adapter.PlatformInterface = &boxPlatformInterfaceWrapper{} -func (w *boxPlatformInterfaceWrapper) ReadWIFIState() adapter.WIFIState { - state := strings.Split(intfBox.WIFIState(), ",") - return adapter.WIFIState{ - SSID: state[0], - BSSID: state[1], - } -} +type boxPlatformInterfaceWrapper struct{} func (w *boxPlatformInterfaceWrapper) Initialize(n adapter.NetworkManager) error { return nil } +// ---- TUN / 接口控制 ---- + func (w *boxPlatformInterfaceWrapper) UsePlatformAutoDetectInterfaceControl() bool { return true } @@ -53,7 +48,11 @@ func (w *boxPlatformInterfaceWrapper) AutoDetectInterfaceControl(fd int) error { return intfBox.AutoDetectInterfaceControl(int32(fd)) } -func (w *boxPlatformInterfaceWrapper) OpenTun(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { +func (w *boxPlatformInterfaceWrapper) UsePlatformInterface() bool { + return true +} + +func (w *boxPlatformInterfaceWrapper) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { if len(options.IncludeUID) > 0 || len(options.ExcludeUID) > 0 { return nil, E.New("android: unsupported uid options") } @@ -66,20 +65,20 @@ func (w *boxPlatformInterfaceWrapper) OpenTun(options *tun.Options, platformOpti if err != nil { return nil, fmt.Errorf("intfBox.OpenTun: %v", err) } - // Do you want to close it? tunFd, err = syscall.Dup(tunFd) if err != nil { return nil, fmt.Errorf("syscall.Dup: %v", err) } - // options.FileDescriptor = int(tunFd) return tun.New(*options) } -func (w *boxPlatformInterfaceWrapper) CloseTun() error { +func (w *boxPlatformInterfaceWrapper) ProcessPlatformOptions(options option.TunPlatformOptions) error { return nil } +// ---- 默认接口监听 ---- + func (w *boxPlatformInterfaceWrapper) UsePlatformDefaultInterfaceMonitor() bool { return true } @@ -88,77 +87,167 @@ func (w *boxPlatformInterfaceWrapper) CreateDefaultInterfaceMonitor(l logger.Log return &interfaceMonitorStub{} } -func (w *boxPlatformInterfaceWrapper) UsePlatformInterfaceGetter() bool { +// ---- 网络接口列表 ---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformNetworkInterfaces() bool { return false } -func (w *boxPlatformInterfaceWrapper) Interfaces() ([]adapter.NetworkInterface, error) { - return nil, errors.New("wtf") +func (w *boxPlatformInterfaceWrapper) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, errors.New("android: platform network interfaces not supported") +} + +// ---- 网络扩展 / DNS ---- + +func (w *boxPlatformInterfaceWrapper) UnderNetworkExtension() bool { + return false } -func (w *boxPlatformInterfaceWrapper) IncludeAllNetworks() bool { +func (w *boxPlatformInterfaceWrapper) NetworkExtensionIncludeAllNetworks() bool { return false } -func (w *boxPlatformInterfaceWrapper) SendNotification(notification *platform.Notification) error { - return nil +func (w *boxPlatformInterfaceWrapper) ClearDNSCache() { } -func (s *boxPlatformInterfaceWrapper) SystemCertificates() []string { +// ---- WIFI ---- + +func (w *boxPlatformInterfaceWrapper) RequestPermissionForWIFIState() error { return nil } -// Android not using +func (w *boxPlatformInterfaceWrapper) ReadWIFIState(ctx context.Context) adapter.WIFIState { + state := strings.Split(intfBox.WIFIState(), ",") + return adapter.WIFIState{ + SSID: state[0], + BSSID: state[1], + } +} -func (w *boxPlatformInterfaceWrapper) UnderNetworkExtension() bool { +func (w *boxPlatformInterfaceWrapper) UsePlatformWIFIMonitor() bool { return false } -func (w *boxPlatformInterfaceWrapper) ClearDNSCache() { -} +// ---- 连接归属 ---- -// process.Searcher +func (w *boxPlatformInterfaceWrapper) UsePlatformConnectionOwnerFinder() bool { + return true +} -func (w *boxPlatformInterfaceWrapper) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*process.Info, error) { +func (w *boxPlatformInterfaceWrapper) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { var uid int32 + network := N.NetworkTCP + if request.IpProtocol == syscall.IPPROTO_UDP { + network = N.NetworkUDP + } + source := netip.AddrPortFrom( + netip.MustParseAddr(request.SourceAddress), uint16(request.SourcePort), + ) + destination := netip.AddrPortFrom( + netip.MustParseAddr(request.DestinationAddress), uint16(request.DestinationPort), + ) + if useProcfs { uid = procfs.ResolveSocketByProcSearch(network, source, destination) if uid == -1 { return nil, E.New("procfs: not found") } } else { - var ipProtocol int32 - switch N.NetworkName(network) { - case N.NetworkTCP: - ipProtocol = syscall.IPPROTO_TCP - case N.NetworkUDP: - ipProtocol = syscall.IPPROTO_UDP - default: - return nil, E.New("unknown network: ", network) - } var err error - uid, err = intfBox.FindConnectionOwner(ipProtocol, source.Addr().String(), int32(source.Port()), destination.Addr().String(), int32(destination.Port())) + uid, err = intfBox.FindConnectionOwner( + request.IpProtocol, + request.SourceAddress, request.SourcePort, + request.DestinationAddress, request.DestinationPort, + ) if err != nil { return nil, err } } + packageName, _ := intfBox.PackageNameByUid(uid) - return &process.Info{UserId: uid, PackageName: packageName}, nil + return &adapter.ConnectionOwner{ + ProcessID: 0, + UserId: uid, + ProcessPath: "", + AndroidPackageNames: []string{packageName}, + }, nil } -// io.Writer +// ---- 通知 ---- -var disableSingBoxLog = false +func (w *boxPlatformInterfaceWrapper) UsePlatformNotification() bool { + return false +} -func (w *boxPlatformInterfaceWrapper) Write(p []byte) (n int, err error) { - // use neko_log - if !disableSingBoxLog { - log.Print(string(p)) - } - return len(p), nil +func (w *boxPlatformInterfaceWrapper) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (w *boxPlatformInterfaceWrapper) CancelNotification(identifier string, typeID int32) error { + return nil +} + +// ---- 地址 ---- + +func (w *boxPlatformInterfaceWrapper) MyInterfaceAddress() []netip.Addr { + return nil +} + +// ---- 邻居解析(Android 不支持)---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformNeighborResolver() bool { + return false +} + +func (w *boxPlatformInterfaceWrapper) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (w *boxPlatformInterfaceWrapper) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +// ---- Shell 会话(Android 不支持)---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformShell() bool { + return false +} + +func (w *boxPlatformInterfaceWrapper) CheckPlatformShell() error { + return nil +} + +func (w *boxPlatformInterfaceWrapper) OpenShellSession(user *adapter.PlatformUser, command string, env []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { + return nil, E.New("android: platform shell not supported") +} + +func (w *boxPlatformInterfaceWrapper) LookupUser(username string) (*adapter.PlatformUser, error) { + return nil, nil +} + +func (w *boxPlatformInterfaceWrapper) LookupSFTPServer() (string, error) { + return "", nil +} + +func (w *boxPlatformInterfaceWrapper) ReadSystemSSHHostKey() ([]byte, error) { + return nil, nil +} + +func (w *boxPlatformInterfaceWrapper) TailscaleHostname() string { + return "" +} + +// ---- Bridge(Android 不支持)---- + +func (w *boxPlatformInterfaceWrapper) UsePlatformBridge() bool { + return false +} + +func (w *boxPlatformInterfaceWrapper) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, E.New("android: platform bridge not supported") } -// 日志 +// ---- 日志 ---- type boxPlatformLogWriterWrapper struct { } From 14356a4a60fd1894fe79e800909e8022ba71ff48 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:35:27 +0000 Subject: [PATCH 32/62] build: align libcore dependency matrix with sing-box 1.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gvisor: 升到 sing-tun 0.9.0-beta.4 要求的 20260727 版。注意伪版本比较陷阱:旧版 20250325023245 是 14 位时间戳,新版 20260727 只有 8 位,Go 按数字比较会认为旧版更大,必须显式改 require - sing-juicity: v0.0.3 -> v0.3.1(适配 sing 0.9 / sing-quic 0.7,API 兼容) - sing / sing-tun / sing-quic / quic-go 统一到 1.14 的版本矩阵 --- libcore/go.mod | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libcore/go.mod b/libcore/go.mod index 720395b2df..041f2b7ed2 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -3,15 +3,15 @@ module libcore go 1.25.5 require ( - github.com/dyhkwong/sing-juicity v0.0.3 + github.com/dyhkwong/sing-juicity v0.3.1 github.com/gofrs/uuid/v5 v5.4.0 github.com/matsuridayo/libneko v1.0.0 // replaced github.com/miekg/dns v1.1.67 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 - github.com/sagernet/sing v0.7.18 + github.com/sagernet/quic-go v0.61.0-sing-box-mod.6 + github.com/sagernet/sing v0.9.0-beta.4 github.com/sagernet/sing-box v1.0.0 // replaced - github.com/sagernet/sing-tun v0.7.10 + github.com/sagernet/sing-tun v0.9.0-beta.4 github.com/ulikunitz/xz v0.5.15 golang.org/x/mobile v0.0.0-20231108233038-35478a0c49da golang.org/x/sys v0.41.0 @@ -52,11 +52,11 @@ require ( github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect github.com/sagernet/fswatch v0.1.1 // indirect - github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect + github.com/sagernet/gvisor v0.0.0-20260727.0-sing-box-mod.1 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/sagernet/sing-mux v0.3.4 // indirect - github.com/sagernet/sing-quic v0.5.2 // indirect + github.com/sagernet/sing-quic v0.7.1 // indirect github.com/sagernet/sing-shadowsocks v0.2.8 // indirect github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect From 6ae6f93c3c040b8c53b071ee2ba23105c32f742e Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:41:11 +0000 Subject: [PATCH 33/62] fix: sing-juicity module path moved to exclavenetwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.3.1 的 go.mod 声明 module 为 github.com/exclavenetwork/sing-juicity(上游迁移组织),require 与 import 同步改新路径;包名仍是 juicity,代码引用不变。 --- libcore/go.mod | 2 +- libcore/protocol/juicity/outbound.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libcore/go.mod b/libcore/go.mod index 041f2b7ed2..464f118246 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -3,7 +3,7 @@ module libcore go 1.25.5 require ( - github.com/dyhkwong/sing-juicity v0.3.1 + github.com/exclavenetwork/sing-juicity v0.3.1 github.com/gofrs/uuid/v5 v5.4.0 github.com/matsuridayo/libneko v1.0.0 // replaced github.com/miekg/dns v1.1.67 diff --git a/libcore/protocol/juicity/outbound.go b/libcore/protocol/juicity/outbound.go index 0b0db1103d..57cbab4e1b 100644 --- a/libcore/protocol/juicity/outbound.go +++ b/libcore/protocol/juicity/outbound.go @@ -23,7 +23,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/dyhkwong/sing-juicity" + "github.com/exclavenetwork/sing-juicity" "github.com/gofrs/uuid/v5" ) From 76ed58d758aaab83e3ed6e2a7affa3cdf288f4ab Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:44:38 +0000 Subject: [PATCH 34/62] fix: adapt juicity and boxapi to sing 0.9 APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tls.NewSTDClient 新增 logger 参数;tls.Config.Config() 改为 STDConfig() - adapter.ConnectionTracker 在 1.14 新增 RoutedFlow 方法,SbStatsService 补空实现(返回 nil) --- libcore/boxapi/v2ray_stats_service.go | 6 ++++++ libcore/protocol/juicity/outbound.go | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/libcore/boxapi/v2ray_stats_service.go b/libcore/boxapi/v2ray_stats_service.go index 3e96f42d40..1d1f0bca47 100644 --- a/libcore/boxapi/v2ray_stats_service.go +++ b/libcore/boxapi/v2ray_stats_service.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" @@ -86,6 +87,11 @@ func (s *SbStatsService) RoutedConnectionInternal(inbound string, outbound strin return conn } +// sing-box 1.14 的 ConnectionTracker 新增了 RoutedFlow +func (s *SbStatsService) RoutedFlow(ctx context.Context, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) tun.FlowTracker { + return nil +} + func (s *SbStatsService) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn { inbound := metadata.Inbound user := metadata.User diff --git a/libcore/protocol/juicity/outbound.go b/libcore/protocol/juicity/outbound.go index 57cbab4e1b..1ebdec129b 100644 --- a/libcore/protocol/juicity/outbound.go +++ b/libcore/protocol/juicity/outbound.go @@ -65,7 +65,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if options.TLS.ALPN == nil { // not len(options.TLS.ALPN) > 0 options.TLS.ALPN = []string{"h3"} } - tlsConfig, err := tls.NewSTDClient(ctx, options.Server, *options.TLS) + tlsConfig, err := tls.NewSTDClient(ctx, logger, options.Server, *options.TLS) if err != nil { return nil, err } @@ -74,7 +74,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, E.Cause(err, "decode pin cert sha256") } - stdTLSConfig, _ := tlsConfig.Config() + stdTLSConfig, _ := tlsConfig.STDConfig() stdTLSConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { peerHash := certChainHash(rawCerts) if !bytes.Equal(pinCertSha256, peerHash) { From c79c23753efea419320b2dcd14ccace53cc38c50 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:49:01 +0000 Subject: [PATCH 35/62] fix: adapt remaining libcore interfaces to 1.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dialer.DoNotSelectInterface 已移除,删除对应 init 与 import - wireguard 在 1.14 改为 endpoint,删除 RegisterOutbound(保留 RegisterEndpoint) - adapter.DNSTransport 新增 Reset / ExchangeAsync,platformLocalDNSTransport 补实现 - tun.DefaultInterfaceMonitor 新增 RegisterMyInterface / MyInterfaces,stub 补空实现 --- libcore/box.go | 5 ----- libcore/box_include.go | 2 -- libcore/dns_box.go | 10 ++++++++++ libcore/interface_monitor.go | 7 +++++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/libcore/box.go b/libcore/box.go index 882072daad..3417028d33 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -18,7 +18,6 @@ import ( "github.com/sagernet/sing-box/protocol/group" box "github.com/sagernet/sing-box" - "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/service" @@ -26,10 +25,6 @@ import ( "libcore/boxapi" ) -func init() { - dialer.DoNotSelectInterface = true -} - var mainInstance *BoxInstance func VersionBox() string { diff --git a/libcore/box_include.go b/libcore/box_include.go index dcda6ad83c..6454253e13 100644 --- a/libcore/box_include.go +++ b/libcore/box_include.go @@ -89,8 +89,6 @@ func nekoboxAndroidOutboundRegistry() *outbound.Registry { hysteria2.RegisterOutbound(registry) juicity.RegisterOutbound(registry) - wireguard.RegisterOutbound(registry) - return registry } diff --git a/libcore/dns_box.go b/libcore/dns_box.go index 3f1cd5fbbb..834e515b1c 100644 --- a/libcore/dns_box.go +++ b/libcore/dns_box.go @@ -54,6 +54,16 @@ func (p *platformLocalDNSTransport) Close() error { return nil } +func (p *platformLocalDNSTransport) Reset() { +} + +func (p *platformLocalDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + response, err := p.Exchange(ctx, message) + callback(response, err) + }() +} + func (p *platformLocalDNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if p.raw && rawQueryFunc != nil { // Raw - Android 10 及以上才有 diff --git a/libcore/interface_monitor.go b/libcore/interface_monitor.go index 0167666e76..e96211dc5d 100644 --- a/libcore/interface_monitor.go +++ b/libcore/interface_monitor.go @@ -34,6 +34,13 @@ func (s *interfaceMonitorStub) RegisterCallback(callback tun.DefaultInterfaceUpd return nil } +func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { +} + +func (s *interfaceMonitorStub) MyInterfaces() []string { + return nil +} + func (s *interfaceMonitorStub) UnregisterCallback(element *list.Element[tun.DefaultInterfaceUpdateCallback]) { } From 3f9b43c0ced90ba05bad6b75056e117e4b49d8bb Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 16:52:12 +0000 Subject: [PATCH 36/62] fix: remove duplicate RegisterMyInterface in interface monitor stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮补 MyInterfaces 时未先检查,文件里本就有 RegisterMyInterface,导致重复声明。 --- libcore/interface_monitor.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/libcore/interface_monitor.go b/libcore/interface_monitor.go index e96211dc5d..a04819ed46 100644 --- a/libcore/interface_monitor.go +++ b/libcore/interface_monitor.go @@ -34,9 +34,6 @@ func (s *interfaceMonitorStub) RegisterCallback(callback tun.DefaultInterfaceUpd return nil } -func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { -} - func (s *interfaceMonitorStub) MyInterfaces() []string { return nil } From 8eedf1937c51807cba200f62cd101645b6c81787 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 17:21:17 +0000 Subject: [PATCH 37/62] feat: migrate generated config to sing-box 1.14 formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.14 移除了 1.12 起废弃的配置写法,旧配置会在启动时报 decode config 失败。 用官方 sing-box v1.14.0 二进制 check 逐项验证并修正: - DNS 服务器改为 type/server/server_port/path 新格式,地址字符串自动拆解 - dns.fakeip 对象移除,改用 fakeip 类型服务器;hosts 服务器用 predefined 字段 - rcode://success 服务器移除,改为 DNS 规则 action=predefined rcode=NOERROR - hosts 规则不再依赖 ip_accept_any(1.14 响应匹配需前置 evaluate),改为按域名精确路由 - tun inbound 的 inet4_address/inet6_address 改为 address(1.12 移除) - inbound 的 sniff / sniff_override_destination / domain_strategy 移除(1.13 移除),改用路由规则 action=sniff / action=resolve --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 131 ++++++++++++------ .../java/moe/matsuri/nb4a/SingBoxOptions.java | 46 +++--- 2 files changed, 118 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index ebe2ae752e..640668895b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -72,6 +72,51 @@ private fun sanitizeDnsEntry(value: String): String { return value.filterNot { it.isISOControl() }.trim() } +// sing-box 1.12+ 新 DNS 服务器格式:把旧版地址字符串拆成 type / server / server_port / path +private fun DNSServerOptions.applyDnsAddress(address: String) { + val value = address.trim() + val hasScheme = value.contains("://") + val scheme = if (hasScheme) value.substringBefore("://").lowercase() else "" + val hostPart = if (hasScheme) value.substringAfter("://") else value + when (val typeName = scheme.ifEmpty { + when (hostPart.lowercase()) { + "local", "fakeip", "dhcp" -> hostPart.lowercase() + else -> "udp" + } + }) { + "local" -> type = "local" + "fakeip" -> type = "fakeip" + "dhcp" -> { + type = "dhcp" + if (hasScheme && hostPart.isNotBlank()) interface_ = hostPart + } + "https", "h3" -> { + type = typeName + val url = (if (hasScheme) value else "https://$value").toHttpUrlOrNull() + ?: throw Exception("Invalid DNS address: $address") + server = url.host + if (url.port != 443) server_port = url.port + path = url.encodedPath.takeIf { it.isNotBlank() && it != "/" } + } + "tls", "quic", "tcp", "udp" -> { + type = typeName + val hostPort = hostPart.trim() + when { + hostPort.startsWith("[") -> { + server = hostPort.substringAfter("[").substringBefore("]") + hostPort.substringAfter("]", "").removePrefix(":").toIntOrNull()?.let { server_port = it } + } + hostPort.count { it == ':' } == 1 -> { + server = hostPort.substringBefore(":") + hostPort.substringAfter(":").toIntOrNull()?.let { server_port = it } + } + else -> server = hostPort + } + } + else -> throw Exception("Unsupported DNS address: $address") + } +} + private fun parseDnsHosts(value: String): Map> { val hosts = linkedMapOf>() value.lineSequence().forEach { line -> @@ -79,7 +124,7 @@ private fun parseDnsHosts(value: String): Map> { if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach val tokens = trimmed.split("\\s+".toRegex()) if (tokens.size < 2) return@forEach - val domain = tokens.first() + val domain = tokens.first().lowercase() val addresses = tokens.drop(1).filter { it.isIpAddress() } if (addresses.isEmpty()) return@forEach hosts.getOrPut(domain) { mutableListOf() }.addAll(addresses) @@ -267,23 +312,22 @@ fun buildConfig( } endpoint_independent_nat = true mtu = DataStore.mtu - domain_strategy = genDomainStrategy(DataStore.resolveDestination) auto_route = true strict_route = DataStore.strictRoute - sniff = needSniff - sniff_override_destination = needSniffOverride when (ipv6Mode) { IPv6Mode.DISABLE -> { - inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") + address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") } IPv6Mode.ONLY -> { - inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") + address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") } else -> { - inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28") - inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126") + address = listOf( + VpnService.PRIVATE_VLAN4_CLIENT + "/28", + VpnService.PRIVATE_VLAN6_CLIENT + "/126" + ) } } }) @@ -292,9 +336,6 @@ fun buildConfig( tag = TAG_MIXED listen = bind listen_port = DataStore.mixedPort - domain_strategy = genDomainStrategy(DataStore.resolveDestination) - sniff = needSniff - sniff_override_destination = needSniffOverride if (DataStore.mixedInboundHasAuth) { users = listOf(User().also { u -> u.username = DataStore.mixedUsername @@ -809,8 +850,8 @@ fun buildConfig( -2L -> { if (shouldAddDnsRule) { userDNSRuleList += makeDnsRuleObj().apply { - server = "dns-block" - disable_cache = true + _hack_config_map["action"] = "predefined" + _hack_config_map["rcode"] = "NOERROR" } } @@ -820,8 +861,8 @@ fun buildConfig( if (tag.startsWith("ruleset-") && tagInfo != null && !tagInfo.second) { userDNSRuleList += DNSRule_DefaultOptions().apply { rule_set = mutableListOf(tag) - server = "dns-block" - disable_cache = true + _hack_config_map["action"] = "predefined" + _hack_config_map["rcode"] = "NOERROR" } } } @@ -917,40 +958,35 @@ fun buildConfig( } dns.servers.add(DNSServerOptions().apply { - address = "rcode://success" - tag = "dns-block" - }) - - dns.servers.add(DNSServerOptions().apply { - address = "local" + type = "local" tag = "dns-local" detour = TAG_DIRECT }) directDNS.firstOrNull().let { dns.servers.add(DNSServerOptions().apply { - address = it ?: throw Exception("No direct DNS, check your settings!") + applyDnsAddress(it ?: throw Exception("No direct DNS, check your settings!")) tag = "dns-direct" detour = TAG_DIRECT - address_resolver = "dns-local" - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) + domain_resolver = "dns-local" + domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) } remoteDns.firstOrNull().let { // Always use direct DNS for urlTest if (!forTest) dns.servers.add(DNSServerOptions().apply { - address = it ?: throw Exception("No remote DNS, check your settings!") + applyDnsAddress(it ?: throw Exception("No remote DNS, check your settings!")) tag = "dns-remote" - address_resolver = "dns-direct" - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) + domain_resolver = "dns-direct" + domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) } if (dnsHosts.isNotEmpty()) { dns.servers.add(DNSServerOptions().apply { + type = "hosts" tag = TAG_DNS_HOSTS - _hack_config_map["type"] = "hosts" - _hack_config_map["predefined"] = dnsHosts + predefined = dnsHosts }) } @@ -987,17 +1023,13 @@ fun buildConfig( source_ip_cidr = listOf("224.0.0.0/3", "ff00::/8") action = "reject" }) - // FakeDNS obj + // FakeDNS if (useFakeDns) { - dns.fakeip = DNSFakeIPOptions().apply { - enabled = true - inet4_range = "198.18.0.0/15" - inet6_range = "fc00::/18" - } dns.servers.add(DNSServerOptions().apply { - address = "fakeip" + type = "fakeip" tag = "dns-fake" - strategy = "ipv4_only" + inet4_range = "198.18.0.0/15" + inet6_range = "fc00::/18" }) dns.rules.add(DNSRule_DefaultOptions().apply { inbound = listOf(deviceInboundTag) @@ -1008,8 +1040,9 @@ fun buildConfig( } if (dnsHosts.isNotEmpty()) { dns.rules.add(0, DNSRule_DefaultOptions().apply { + // hosts 服务器精确匹配域名,未命中会返回 NXDOMAIN,因此只在请求阶段命中 hosts 域名时才走它 + domain = dnsHosts.keys.map { "full:${it.lowercase()}" } server = TAG_DNS_HOSTS - _hack_config_map["ip_accept_any"] = true }) } // avoid loopback @@ -1032,13 +1065,13 @@ fun buildConfig( val serverTag = "dns-sub-$gid" dns.servers.add(DNSServerOptions().apply { - address = resolver + applyDnsAddress(resolver) tag = serverTag detour = TAG_DIRECT if (!resolver.isIpAddress()) { - address_resolver = "dns-direct" + domain_resolver = "dns-direct" } - strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("server")) + domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("server")) }) dns.rules.add(0, DNSRule_DefaultOptions().apply { makeSingBoxRule(hosts) @@ -1047,6 +1080,22 @@ fun buildConfig( } } + if (!forTest) { + // 1.13+ 起 inbound 的 sniff / domain_strategy 字段被移除,改用路由规则动作 + if (needSniff) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "sniff" + }) + } + val resolveStrategy = genDomainStrategy(DataStore.resolveDestination) + if (resolveStrategy.isNotEmpty()) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "resolve" + _hack_config_map["strategy"] = resolveStrategy + }) + } + } + if (!forTest) _hack_custom_config = DataStore.globalCustomConfig }.let { val configMap = it.asMap() diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java index 0cfb24a225..0195bc9327 100644 --- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -312,8 +312,6 @@ public static class DNSOptions extends SingBoxOption { public Boolean reverse_mapping; - public DNSFakeIPOptions fakeip; - // Generate note: nested type DNSClientOptions public String strategy; @@ -329,41 +327,48 @@ public static class DNSOptions extends SingBoxOption { public static class DNSServerOptions extends SingBoxOption { + public String type; + public String tag; - public String address; + public String server; - public String address_resolver; + public Integer server_port; - public String address_strategy; + public String path; - public Long address_fallback_delay; + public String detour; - public String strategy; + public String domain_resolver; - public String detour; + public String domain_strategy; - } + public Map> predefined; - public static class DNSClientOptions extends SingBoxOption { + public String inet4_range; - public String strategy; + public String inet6_range; - public Boolean disable_cache; + public String method; - public Boolean disable_expire; + public Map headers; - public Boolean independent_cache; + @SerializedName("interface") + public String interface_; + + public Boolean prefer_go; } - public static class DNSFakeIPOptions extends SingBoxOption { + public static class DNSClientOptions extends SingBoxOption { - public Boolean enabled; + public String strategy; - public String inet4_range; + public Boolean disable_cache; - public String inet6_range; + public Boolean disable_expire; + + public Boolean independent_cache; } @@ -2828,6 +2833,11 @@ public static class Inbound_TunOptions extends Inbound { public Integer mtu; + // Generate note: Listable + public List address; + + public String dns_mode; + // Generate note: Listable public List inet4_address; From 2a9e8227970b366df310cffa75fc01359afc46f6 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 17:34:03 +0000 Subject: [PATCH 38/62] fix: migrate TLS fragment to route action for sing-box 1.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.14 移除了 direct outbound 的 fragment 字段,启动时报 unknown field。 - fragment 出站保留为纯 direct 跳板(仍被 detour 引用) - 分片改用路由动作 action=route-options + tls_fragment=true - 顺带补 route.default_domain_resolver,消除出站缺少域名解析器的废弃警告(1.16 将变强制) --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index 640668895b..2c8f398b34 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -911,15 +911,11 @@ fun buildConfig( }) if (DataStore.enableTLSFragment) { - val fragmentOutbound = Outbound().apply { + // 1.14 移除了 direct outbound 的 fragment 字段,分片改由路由动作实现 + outbounds.add(Outbound().apply { tag = TAG_FRAGMENT type = "direct" - _hack_config_map["fragment"] = Fragment().apply { - length = DataStore.fragmentLength - interval = DataStore.fragmentInterval - }.asMap() - } - outbounds.add(fragmentOutbound) + }) } fun isExclusiveCustomHost(host: String): Boolean { @@ -1081,6 +1077,16 @@ fun buildConfig( } if (!forTest) { + // 1.12+ 起出站缺省域名解析器由 route 统一指定(1.16 将改为强制) + route._hack_config_map["default_domain_resolver"] = "dns-direct" + + // 1.12+ 起 TLS 分片改为路由动作(原 fragment outbound 字段已移除) + if (DataStore.enableTLSFragment) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "route-options" + _hack_config_map["tls_fragment"] = true + }) + } // 1.13+ 起 inbound 的 sniff / domain_strategy 字段被移除,改用路由规则动作 if (needSniff) { route.rules.add(0, Rule_DefaultOptions().apply { From 25b34bcb586e2b3e969ea38a31f20243f5467213 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 17:53:20 +0000 Subject: [PATCH 39/62] fix: drop route.concurrent_dial and add dedicated DNS direct outbound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1.14 移除了 route.concurrent_dial - 1.14 拒绝 detour 到空的 direct 出站,为 DNS 服务器单独建 dns-direct-out(udp_fragment=false) --- .../io/nekohasekai/sagernet/fmt/ConfigBuilder.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index 2c8f398b34..d46565fa1d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -54,6 +54,7 @@ const val TAG_BYPASS = "bypass" const val TAG_BLOCK = "block" const val TAG_FRAGMENT = "fragment" const val TAG_DNS_HOSTS = "dns-hosts" +const val TAG_DNS_DIRECT_OUT = "dns-direct-out" const val LOCALHOST = "127.0.0.1" @@ -353,9 +354,6 @@ fun buildConfig( override_android_vpn = true rules = mutableListOf() rule_set = mutableListOf() - - // 添加并发拨号设置 - concurrent_dial = DataStore.concurrentDial } // returns outbound tag @@ -910,6 +908,13 @@ fun buildConfig( type = "direct" }) + // 1.14 拒绝 detour 到「空 direct 出站」,DNS 服务器需要带参数的直连出站 + outbounds.add(Outbound().apply { + tag = TAG_DNS_DIRECT_OUT + type = "direct" + _hack_config_map["udp_fragment"] = false + }) + if (DataStore.enableTLSFragment) { // 1.14 移除了 direct outbound 的 fragment 字段,分片改由路由动作实现 outbounds.add(Outbound().apply { @@ -956,14 +961,14 @@ fun buildConfig( dns.servers.add(DNSServerOptions().apply { type = "local" tag = "dns-local" - detour = TAG_DIRECT + detour = TAG_DNS_DIRECT_OUT }) directDNS.firstOrNull().let { dns.servers.add(DNSServerOptions().apply { applyDnsAddress(it ?: throw Exception("No direct DNS, check your settings!")) tag = "dns-direct" - detour = TAG_DIRECT + detour = TAG_DNS_DIRECT_OUT domain_resolver = "dns-local" domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) From 9a669b97d03d6baab15de93a6dc0f3d9d771061b Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 18:01:12 +0000 Subject: [PATCH 40/62] fix: align snell outbound with sing-box 1.14 union options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version 归一化为 4/6(1.14 只接受这两种) - 移除 quic_proxy_mode(已从 1.14 删除) - obfs_mode 按 1.14 枚举传 tls,不再用空串表示 tls --- .../sagernet/fmt/snell/SnellBuildConfig.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt index 91069e1aaa..aba4124dc2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBuildConfig.kt @@ -3,6 +3,8 @@ package io.nekohasekai.sagernet.fmt.snell import moe.matsuri.nb4a.SingBoxOptions fun buildSingBoxOutboundSnellBean(bean: SnellBean): SingBoxOptions.Outbound_SnellOptions { + // sing-box 1.14 的 snell outbound 只接受 version 4 / 6 两种联合结构 + val version = if (bean.version == 6) 6 else 4 return SingBoxOptions.Outbound_SnellOptions().apply { type = "snell" server = bean.serverAddress @@ -11,23 +13,25 @@ fun buildSingBoxOutboundSnellBean(bean: SnellBean): SingBoxOptions.Outbound_Snel if (!bean.userKey.isNullOrBlank()) { userkey = bean.userKey } - version = bean.version + this.version = version if (bean.network != null && bean.network.isNotBlank()) { network = bean.network } - if (bean.version == 6) { - if (!bean.mode.isNullOrBlank() && bean.mode != "default") { - mode = bean.mode + if (version == 6) { + // 1.14 的 v6 只有 mode(default / unshaped / unsafe-raw),quic_proxy_mode 已被移除 + val snellMode = bean.mode?.takeIf { it in setOf("default", "unshaped", "unsafe-raw") } + if (snellMode != null && snellMode != "default") { + mode = snellMode } - if (bean.quicProxyMode == true) { - quic_proxy_mode = true - } - } else if (bean.obfsMode != null && bean.obfsMode.isNotBlank()) { - obfs_mode = if (bean.version != null && bean.version >= 4 && bean.obfsMode == "tls") "" else bean.obfsMode - if (obfs_mode.isNotBlank() && bean.obfsHost != null && bean.obfsHost.isNotBlank()) { - obfs_host = bean.obfsHost + } else if (!bean.obfsMode.isNullOrBlank()) { + val obfsModeValue = bean.obfsMode + if (obfsModeValue in setOf("none", "http", "tls")) { + if (obfsModeValue != "none") obfs_mode = obfsModeValue + if (obfs_mode.isNotBlank() && !bean.obfsHost.isNullOrBlank()) { + obfs_host = bean.obfsHost + } } } From c93fb6205f405237614ecd0d789a614504a44d89 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 18:10:44 +0000 Subject: [PATCH 41/62] fix: stop routing TLS outbounds through the fragment outbound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.14 拒绝 detour 到空的 direct 出站,fragment 出站被简化后触发 "detour to an empty direct outbound makes no sense"。 分片已由 route-options tls_fragment 实现,不再需要 detour 绕行; fragment 出站保留给 hevtun 使用并补上参数。 --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index d46565fa1d..2d26953853 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -528,13 +528,7 @@ fun buildConfig( } } - if (needGlobal && DataStore.enableTLSFragment) { - val outboundMap = currentOutbound.asMap() - val tlsOptions = outboundMap["tls"] as? Map<*, *> - if (tlsOptions?.get("enabled") == true) { - currentOutbound._hack_config_map["detour"] = TAG_FRAGMENT - } - } + // TLS 分片已改为路由动作(route-options tls_fragment),不再用 detour 绕经 fragment 出站 } // internal & external @@ -920,6 +914,7 @@ fun buildConfig( outbounds.add(Outbound().apply { tag = TAG_FRAGMENT type = "direct" + _hack_config_map["udp_fragment"] = false }) } @@ -1081,17 +1076,17 @@ fun buildConfig( } } + // 1.12+ 起 TLS 分片改为路由动作(原 fragment outbound 字段已移除) + if (DataStore.enableTLSFragment) { + route.rules.add(0, Rule_DefaultOptions().apply { + action = "route-options" + _hack_config_map["tls_fragment"] = true + }) + } + if (!forTest) { // 1.12+ 起出站缺省域名解析器由 route 统一指定(1.16 将改为强制) route._hack_config_map["default_domain_resolver"] = "dns-direct" - - // 1.12+ 起 TLS 分片改为路由动作(原 fragment outbound 字段已移除) - if (DataStore.enableTLSFragment) { - route.rules.add(0, Rule_DefaultOptions().apply { - action = "route-options" - _hack_config_map["tls_fragment"] = true - }) - } // 1.13+ 起 inbound 的 sniff / domain_strategy 字段被移除,改用路由规则动作 if (needSniff) { route.rules.add(0, Rule_DefaultOptions().apply { From fb714554995ee2549b019a9e410c974ce6573fc2 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 18:23:20 +0000 Subject: [PATCH 42/62] fix: inject sing-box version at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit constant.Version 默认就是 "unknown",官方靠 -ldflags -X 注入,我们的 gomobile bind 没传, 导致关于页显示 "sing-box: unknown"。补上 SINGBOX_VERSION(默认 v1.14.0)。 --- libcore/build.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libcore/build.sh b/libcore/build.sh index 3290df8646..4c7d78074a 100755 --- a/libcore/build.sh +++ b/libcore/build.sh @@ -5,6 +5,9 @@ source ../buildScript/init/env_ndk.sh BUILD=".build" +# sing-box 的版本号是编译期注入的(constant.Version 默认 "unknown") +SINGBOX_VERSION="${SINGBOX_VERSION:-v1.14.0}" + rm -rf $BUILD/android \ $BUILD/java \ $BUILD/javac-output \ @@ -22,13 +25,14 @@ export GOFLAGS="-mod=mod" # "no exported names in the package command-line-arguments",把真实错误吞掉。 # 这里先单独编译一次,让 API 不兼容等问题直接暴露出来。 echo ">> pre-check: go build" -go build -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 +go build -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' \ + -ldflags="-X github.com/sagernet/sing-box/constant.Version=$SINGBOX_VERSION" . || exit 1 # 可选:GOMOBILE_TARGET 限定 ABI(如 "android/arm64"),不设置时保持原有全量编译 TARGET_ARGS=() if [ -n "${GOMOBILE_TARGET:-}" ]; then TARGET_ARGS=("-target=$GOMOBILE_TARGET") fi -"$GOPATH"/bin/gomobile-matsuri bind "${TARGET_ARGS[@]}" -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags='-s -w' -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 +"$GOPATH"/bin/gomobile-matsuri bind "${TARGET_ARGS[@]}" -v -androidapi 21 -cache "$(realpath $BUILD)" -trimpath -ldflags="-s -w -X github.com/sagernet/sing-box/constant.Version=$SINGBOX_VERSION" -tags='with_conntrack,with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api' . || exit 1 rm -r libcore-sources.jar proj=../app/libs From 76cb64140c91e25d298651f209be716b5572ea9d Mon Sep 17 00:00:00 2001 From: yekaipow Date: Thu, 5 Nov 2026 18:37:37 +0000 Subject: [PATCH 43/62] chore: drop local signing keystore and personal CI workflow for upstream PR --- .github/workflows/build-apk.yml | 478 -------------------------------- debug.keystore | Bin 2730 -> 0 bytes 2 files changed, 478 deletions(-) delete mode 100644 .github/workflows/build-apk.yml delete mode 100644 debug.keystore diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml deleted file mode 100644 index b2748e932d..0000000000 --- a/.github/workflows/build-apk.yml +++ /dev/null @@ -1,478 +0,0 @@ -name: Android CI - -# main 分支唯一的构建入口。 -# ci.yml 仅 pull_request 触发(PR 校验) -# build.yml 仅手动触发(原 nb4a.properties 触发已与这里重叠,已移除) -# preview.yml / release.yml 仅手动触发 -# 这样一次 push 只会产生一条运行记录,避免重复构建。 -# -# 编译 NekoBox APK 的完整流水线: -# native job -> 生成 libcore.aar(Go/gomobile)与 hev-tun 的 .so(NDK) -# build job -> 下载 native 产物 + 下载 geo 资源 + Gradle 打包 APK -on: - push: - branches: - - main - paths: - - 'app/**' - - 'buildSrc/**' - - 'buildScript/**' - - 'libcore/**' - - 'gradle/**' - - 'build.gradle.kts' - - 'settings.gradle.kts' - - 'gradle.properties' - - 'gradlew' - - 'nb4a.properties' - - '.github/workflows/build-apk.yml' - workflow_dispatch: - inputs: - flavor: - description: '产品风味' - type: choice - options: - - oss - - fdroid - - play - - preview - default: oss - build_type: - description: '构建类型' - type: choice - options: - - release - - debug - default: release - abi: - description: '目标 ABI' - type: choice - options: - - arm64-v8a - - armeabi-v7a - - x86 - - x86_64 - - all - default: arm64-v8a - -permissions: - contents: read - -concurrency: - group: android-ci-${{ github.ref }} - cancel-in-progress: true - -env: - ANDROID_SDK_ROOT: /usr/local/lib/android/sdk - ANDROID_HOME: /usr/local/lib/android/sdk - SDK_PLATFORM: android-35 # buildSrc/Helpers.kt: compileSdk = 35 - BUILD_TOOLS: 35.0.1 # buildSrc/Helpers.kt: buildToolsVersion = 35.0.1 - NDK_VERSION: 25.0.8775105 # buildScript/init/env_ndk.sh 首选版本 - GRADLE_ARGS: --no-daemon --stacktrace - FLAVOR: ${{ inputs.flavor || 'oss' }} - BUILD_TYPE: ${{ inputs.build_type || 'release' }} - ABI: ${{ inputs.abi || 'arm64-v8a' }} - -jobs: - # ---------------------------------------------------------------- native - native: - name: Native libs (libcore + hevtun) - runs-on: ubuntu-latest - timeout-minutes: 90 - if: ${{ !startsWith(github.event.head_commit.message, '[skip ci]') }} - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - submodules: recursive - - - name: Setup Java JDK - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '17' - - - name: Restore Android SDK cache - id: sdk-cache - uses: actions/cache/restore@v4 - with: - path: | - /usr/local/lib/android/sdk/platforms/android-35 - /usr/local/lib/android/sdk/build-tools/35.0.1 - key: android-sdk-${{ runner.os }}-35-35.0.1 - restore-keys: | - android-sdk-${{ runner.os }}- - - - name: Restore Android NDK cache - id: ndk-cache - uses: actions/cache/restore@v4 - with: - path: /usr/local/lib/android/sdk/ndk/25.0.8775105 - key: android-ndk-${{ runner.os }}-25.0.8775105 - - - name: Install missing Android SDK / NDK components - if: steps.sdk-cache.outputs.cache-hit != 'true' || steps.ndk-cache.outputs.cache-hit != 'true' - run: | - SDK="$ANDROID_SDK_ROOT" - SDKMGR="$SDK/cmdline-tools/latest/bin/sdkmanager" - if [ ! -x "$SDKMGR" ]; then - echo "::error::sdkmanager not found at $SDKMGR" - exit 1 - fi - yes | "$SDKMGR" --licenses > /dev/null 2>&1 || true - if [ ! -d "$SDK/platforms/$SDK_PLATFORM" ]; then - yes | "$SDKMGR" "platforms;$SDK_PLATFORM" - fi - if [ ! -d "$SDK/build-tools/$BUILD_TOOLS" ]; then - yes | "$SDKMGR" "build-tools;$BUILD_TOOLS" - fi - if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then - yes | "$SDKMGR" "ndk;$NDK_VERSION" || true - fi - # 兜底:若该 NDK 版本已从仓库移除,退到 runner 上最新的 NDK - if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then - ALT=$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1) - if [ -z "$ALT" ]; then - echo "::error::既没有 $NDK_VERSION,runner 上也没有任何 NDK" - exit 1 - fi - ALT="${ALT%/}" - echo "::warning::$NDK_VERSION 不可用,回退到 $ALT" - echo "ANDROID_NDK_HOME=$ALT" >> "$GITHUB_ENV" - fi - echo "platforms: $(ls "$SDK/platforms" | tr '\n' ' ')" - echo "build-tools: $(ls "$SDK/build-tools" | tr '\n' ' ')" - echo "ndk: $(ls "$SDK/ndk" | tr '\n' ' ')" - - - name: Resolve ABI - run: | - case "$ABI" in - arm64-v8a) echo "GOMOBILE_TARGET=android/arm64" >> "$GITHUB_ENV"; echo "HEV_ABIS=arm64-v8a" >> "$GITHUB_ENV" ;; - armeabi-v7a) echo "GOMOBILE_TARGET=android/arm" >> "$GITHUB_ENV"; echo "HEV_ABIS=armeabi-v7a" >> "$GITHUB_ENV" ;; - x86) echo "GOMOBILE_TARGET=android/386" >> "$GITHUB_ENV"; echo "HEV_ABIS=x86" >> "$GITHUB_ENV" ;; - x86_64) echo "GOMOBILE_TARGET=android/amd64" >> "$GITHUB_ENV"; echo "HEV_ABIS=x86_64" >> "$GITHUB_ENV" ;; - all) echo "HEV_ABIS=armeabi-v7a arm64-v8a x86 x86_64" >> "$GITHUB_ENV" ;; - *) echo "::error::未知 ABI: $ABI"; exit 1 ;; - esac - echo "ABI=$ABI -> gomobile target=${GOMOBILE_TARGET:-全部}, hevtun ABI=${HEV_ABIS}" - - - name: Compute native cache keys - run: | - find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status - git ls-files libcore | xargs cat | sha1sum > libcore_status - git submodule status hev-socks5-tunnel > hev_status - sha1sum buildScript/compile-hevtun.sh >> hev_status - echo "libcore deps: $(cat golang_status libcore_status | tr -d '\n')" - echo "hevtun deps: $(cat hev_status | tr -d '\n')" - - - name: Restore libcore cache - id: libcore-cache - uses: actions/cache/restore@v4 - with: - path: app/libs/libcore.aar - key: libcore-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('golang_status', 'libcore_status') }} - restore-keys: | - libcore-${{ runner.os }}-${{ env.ABI }}- - - - name: Setup Golang - if: steps.libcore-cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v6 - with: - go-version: '^1.25' - cache: false - - - name: Build libcore (Go + gomobile) - if: steps.libcore-cache.outputs.cache-hit != 'true' - run: | - # libcore/init.sh 用裸命令调用 gomobile-matsuri,当前 step 的 PATH 必须同步导出 - export PATH="$(go env GOPATH)/bin:$PATH" - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - echo "NDK: ${ANDROID_NDK_HOME:-$ANDROID_SDK_ROOT/ndk/$NDK_VERSION}" - ./run lib core - ls -lh app/libs/libcore.aar - - - name: Restore hevtun cache - id: hevtun-cache - uses: actions/cache/restore@v4 - with: - path: app/src/main/jniLibs - key: hevtun-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('hev_status') }} - restore-keys: | - hevtun-${{ runner.os }}-${{ env.ABI }}- - - - name: Build hevtun (NDK, 4 ABIs) - if: steps.hevtun-cache.outputs.cache-hit != 'true' - run: | - bash buildScript/compile-hevtun.sh - find app/src/main/jniLibs -name '*.so' -exec ls -lh {} \; - - - name: Save libcore cache - if: steps.libcore-cache.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@v4 - with: - path: app/libs/libcore.aar - key: libcore-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('golang_status', 'libcore_status') }} - - - name: Save hevtun cache - if: steps.hevtun-cache.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@v4 - with: - path: app/src/main/jniLibs - key: hevtun-${{ runner.os }}-${{ env.ABI }}-${{ hashFiles('hev_status') }} - - - name: Save Android SDK cache - if: steps.sdk-cache.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@v4 - with: - path: | - /usr/local/lib/android/sdk/platforms/android-35 - /usr/local/lib/android/sdk/build-tools/35.0.1 - key: android-sdk-${{ runner.os }}-35-35.0.1 - - - name: Save Android NDK cache - if: steps.ndk-cache.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@v4 - with: - path: /usr/local/lib/android/sdk/ndk/25.0.8775105 - key: android-ndk-${{ runner.os }}-25.0.8775105 - - # 交给 build job,避免依赖缓存 key 的二次命中 - - name: Upload libcore aar - uses: actions/upload-artifact@v7 - with: - name: libcore-aar - path: app/libs/libcore.aar - retention-days: 1 - if-no-files-found: error - - - name: Upload hevtun libs - uses: actions/upload-artifact@v7 - with: - name: hevtun-libs - path: app/src/main/jniLibs - retention-days: 1 - if-no-files-found: error - - # ----------------------------------------------------------------- build - build: - name: Build ${{ inputs.flavor || 'oss' }} APK - runs-on: ubuntu-latest - needs: - - native - timeout-minutes: 60 - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Setup Java JDK - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '17' - - - name: Restore Android SDK cache - id: sdk-cache - uses: actions/cache/restore@v4 - with: - path: | - /usr/local/lib/android/sdk/platforms/android-35 - /usr/local/lib/android/sdk/build-tools/35.0.1 - key: android-sdk-${{ runner.os }}-35-35.0.1 - restore-keys: | - android-sdk-${{ runner.os }}- - - - name: Restore Android NDK cache - id: ndk-cache - uses: actions/cache/restore@v4 - with: - path: /usr/local/lib/android/sdk/ndk/25.0.8775105 - key: android-ndk-${{ runner.os }}-25.0.8775105 - - - name: Install missing Android SDK / NDK components - if: steps.sdk-cache.outputs.cache-hit != 'true' || steps.ndk-cache.outputs.cache-hit != 'true' - run: | - SDK="$ANDROID_SDK_ROOT" - SDKMGR="$SDK/cmdline-tools/latest/bin/sdkmanager" - if [ ! -x "$SDKMGR" ]; then - echo "::error::sdkmanager not found at $SDKMGR" - exit 1 - fi - yes | "$SDKMGR" --licenses > /dev/null 2>&1 || true - [ -d "$SDK/platforms/$SDK_PLATFORM" ] || yes | "$SDKMGR" "platforms;$SDK_PLATFORM" - [ -d "$SDK/build-tools/$BUILD_TOOLS" ] || yes | "$SDKMGR" "build-tools;$BUILD_TOOLS" - if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then - yes | "$SDKMGR" "ndk;$NDK_VERSION" || true - fi - if [ ! -f "$SDK/ndk/$NDK_VERSION/source.properties" ]; then - ALT=$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1) - echo "::warning::$NDK_VERSION 不可用,回退到 ${ALT:-无}" - [ -z "$ALT" ] || echo "ANDROID_NDK_HOME=${ALT%/}" >> "$GITHUB_ENV" - fi - - - name: Download libcore aar - uses: actions/download-artifact@v7 - with: - name: libcore-aar - path: app/libs - - - name: Download hevtun libs - uses: actions/download-artifact@v7 - with: - name: hevtun-libs - path: app/src/main/jniLibs - - - name: Restore Gradle cache - id: gradle-cache - uses: actions/cache/restore@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Prepare local.properties and signing - id: signing - env: - KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} - ALIAS_NAME: ${{ secrets.ALIAS_NAME }} - ALIAS_PASS: ${{ secrets.ALIAS_PASS }} - run: | - echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties - echo "ndk.dir=$ANDROID_SDK_ROOT/ndk/$NDK_VERSION" >> local.properties - # buildSrc/Helpers.kt 读取 release.keystore(已在仓库内),密码来自 env 或 local.properties; - # secrets 不齐时自动改用仓库内固定的 debug.keystore(release/debug 都签名) - for v in KEYSTORE_PASS ALIAS_NAME ALIAS_PASS; do - if [ -n "${!v:-}" ]; then echo "secret $v: 已配置"; else echo "secret $v: 空(未配置)"; fi - done - if [ -n "$KEYSTORE_PASS" ] && [ -n "$ALIAS_NAME" ] && [ -n "$ALIAS_PASS" ]; then - echo "正式签名:release.keystore" - else - echo "::warning::KEYSTORE_PASS / ALIAS_NAME / ALIAS_PASS 未配置,使用固定 debug.keystore 签名" - fi - - - name: Init assets (geoip / geosite) - run: | - ok=0 - for i in 1 2 3; do - if ./run init action gradle; then ok=1; break; fi - echo "::warning::assets 下载失败(第 $i 次),重试中…" - sleep 5 - done - if [ "$ok" != "1" ]; then - echo "::error::geoip/geosite 资源初始化失败" - exit 1 - fi - ls -lh app/src/main/assets/sing-box/ - - - name: Build APK - id: build - env: - BUILD_PLUGIN: none - KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }} - ALIAS_NAME: ${{ secrets.ALIAS_NAME }} - ALIAS_PASS: ${{ secrets.ALIAS_PASS }} - LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - run: | - set -uo pipefail - cap() { echo "${1^}"; } - - BT="$BUILD_TYPE" - # secret 未配置时 GitHub 注入的是空字符串而非 null, - # Helpers.kt 里 `?:` 判断不到 null 会创建密码/别名为空的 signingConfig, - # 必须彻底 unset;此时 Helpers.kt 会给 release 和 debug 都配上仓库内固定证书 - if [ -z "${KEYSTORE_PASS:-}" ] || [ -z "${ALIAS_NAME:-}" ] || [ -z "${ALIAS_PASS:-}" ]; then - unset KEYSTORE_PASS ALIAS_NAME ALIAS_PASS - echo "未配置正式签名 secrets,使用仓库内固定证书(debug.keystore)签名" - fi - TASK="assemble$(cap "$FLAVOR")$(cap "$BT")" - echo "TASK=$TASK" >> "$GITHUB_ENV" - echo "EFFECTIVE_TYPE=$BT" >> "$GITHUB_ENV" - - # 只构建目标 ABI:buildSrc/Helpers.kt 读 NB4A_ABI 限制 splits - # (原先用 -Pandroid.injected.build.abi,AGP 8.8 上会构建成功但不出 APK) - if [ "$ABI" = "all" ]; then - unset NB4A_ABI - else - export NB4A_ABI="$ABI" - fi - echo "==> NB4A_ABI=${NB4A_ABI:-全部} ./gradlew app:$TASK $GRADLE_ARGS" - - set +e - ./gradlew "app:$TASK" $GRADLE_ARGS 2>&1 | tee gradle.log - status=${PIPESTATUS[0]} - if [ "$status" -ne 0 ]; then - # 把编译错误转成 annotation,便于在 Checks 页直接看到 - grep -E "^e: |error:|FAILURE:|What went wrong|Caused by|> Task .* FAILED" gradle.log | head -40 | while IFS= read -r line; do - echo "::error::$line" - done - exit "$status" - fi - - - name: Collect APKs - id: collect - run: | - BASE="app/build/outputs/apk" - if [ "$ABI" = "all" ]; then - PATTERN="*.apk" - else - PATTERN="*${ABI}*.apk" - fi - FILES="" - if [ -d "$BASE" ]; then - FILES=$(find "$BASE" -name "$PATTERN" | sort) - fi - if [ -z "$FILES" ]; then - echo "::error::$BASE 下没有匹配 $PATTERN 的 APK" - echo "--- 实际产出的所有 APK ---" - find app/build -maxdepth 6 -name '*.apk' 2>/dev/null || true - echo "--- outputs 目录结构 ---" - find app/build/outputs -maxdepth 4 -type d 2>/dev/null | head -30 || true - echo "--- gradle 日志尾部 ---" - tail -30 gradle.log 2>/dev/null || true - exit 1 - fi - COUNT=$(printf '%s\n' "$FILES" | grep -c . || true) - { - echo "files<> "$GITHUB_OUTPUT" - echo "=== 产物($COUNT 个)===" - printf '%s\n' "$FILES" | while IFS= read -r f; do ls -lh "$f"; done - { - echo "### APK 产物:$FLAVOR / $EFFECTIVE_TYPE / $ABI" - echo "" - echo "| 文件 | 大小 |" - echo "| --- | --- |" - while IFS= read -r f; do - [ -n "$f" ] && printf '| %s | %s KB |\n' "$(basename "$f")" "$(du -k "$f" | cut -f1)" - done <<< "$FILES" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Verify APK signature - run: | - APKSIGNER=$(ls "$ANDROID_SDK_ROOT"/build-tools/*/apksigner | tail -1) - echo "使用 $APKSIGNER" - printf '%s\n' "${{ steps.collect.outputs.files }}" | while IFS= read -r apk; do - [ -n "$apk" ] || continue - echo "== $apk" - "$APKSIGNER" verify "$apk" - done - - - name: Upload APK - uses: actions/upload-artifact@v7 - with: - name: NekoBox-${{ env.FLAVOR }}-${{ env.EFFECTIVE_TYPE }}-${{ env.ABI }} - path: ${{ steps.collect.outputs.files }} - retention-days: 7 - if-no-files-found: error - - - name: Save Gradle cache - if: steps.gradle-cache.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/wrapper/gradle-wrapper.properties') }} diff --git a/debug.keystore b/debug.keystore deleted file mode 100644 index 4b0319fbc8ed34eba0a4005caa5345856270ac7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2730 zcma);X*d*$8pmhGjAbliU(#5Lk+m7SvPLr_3?kVEWl6~rLgiRS$i5fCVC-8*gJg}d zh3wgClI+Vt$jl+R&U5c`&T~KA`{Dn*&-?$s@B8uhyhsAH1O#M25};;`EHY8pC>lGE z37AiS>VXMRty5bANr3$RUlF7LOn~H`+F7T{U}XKz78?`@$|pc{Pfbmv+us`ycBBnb z{hyW%DGTQ9q>qzZW-s?-RsXbTD=;NR>XOGnASq4|Pz=e&$o%h%AQlJ!31?(+ioyb2 z8NfgpFlPsgIJ{*Kf5<2!=k$#3Rs$x0N5F2mQ)>qnf=hDG?O?vTFRGLS97!={G1PCZ zQFh@Ny?QoLarM|~2_cgluI@bPcRti`gH&ldW?V8Y@Liq(b&c`Q7&%@R?$S4NM*d93 zwLj%C9Vqle$9S<d|_6MeT9+*VW3G?_DDFjzR?q&a%R%n-jiMG(%k z&GvFvy$LBh)4-gH>e(#yUuPU+OFnAfn37BDc5^x?;T&G?L2KpgOgX>jUyWFUnn|g( z2C%9;acOk_Bia)g(^X1ZHM&umNx&~K{ z&J~?E#tAENXGRw{sM&vV$Tt>taNax|2YUxB`W1%v5qRtl4rK;LDmBQIr234f5xY|5 z3VU%<<71Rom*I%^DD4`(Ji`s~2Ol$>i2^$r$RiuN$3!#-KCx^DlbnP1++z9%n2FF+|YL&gGq znfDEBVH!Qt_>EW&=6f-2qUePsrNz{0*@3-&QhQr`mziuHUqL#;I{5v!#($x8G*~b&q z+#Wle3}T)Bxx%`|MJXdOYnFp{Wyii`jv@KVN+7j~r-GQ6U5wPxzgKQ9RTr)yKqT>c z`Vp5~j)(Bl*;43?#tJTDNkon$t|+SRORINv|HwN))-&wLk6pFkk)4C4KV9q!yC2wUp(#H%7eaq{EY~#*-!gB~k}J)KFC6 zZMv4g`dR_p%( z_1Qc~>74(0Us%tcVWmsGau|VOr#ae)ZtH)4(l_V*F>>*cAHZkw=~#VBHJEE$c`(2W zwjIr}niQCMMB1!Sw4vN|6GE3u+1KSX_ zzAvKK)&!zbH0hbdNJ|Pjc}O!$a2>aeB$?%pxKr)yVn?hY~5ONzEHfT*Sw};D@%zn zpmj;V77#SE-nNf}FJNv(B?zJ~1<;=tHI2ro^M9E#GE4a|P)wFV^Qv7(0mn}W%Ow#a z!`>$g)?cP9%Qy@>{U1Cpal9eC?w)ATz4LNK;HgDpuA)HP4NrqS!h2WnhSeD!=@RkOZ7+arowiAg#jg? zoOsZ`%>o=tL%u!7@1ZdE=xGg+dzZ0a@n#M+3_q}vx)m%KZO>9Fr=>YoTfzm*zSPjIE}LygFaSU0eMk$KY4mWK{?sGAyX2D{L0DVlKJz`>#w?99&o+ zI#LlV(Q1+ZnHt%iWYY8h{l1%CjiSvMdqAiHW(sTFKxS5q*_*tFrV0AISXgCcs zTw1i8!{L1KM1d!2KJ1Dqr87tSh#UAgug=GKxe**y_ z0I-;_oZyt|E$oGz-n$7-LXxE)rw$i)GlgokKS+-5Soa@;d1n{PkA@iKC(b&=$0)3U NIwi$k`Td2Qe*wWy>FNLg From c71a4ced15272f5d1adc85033fe01f8619b90d0f Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 01:12:25 +0000 Subject: [PATCH 44/62] feat: add group settings entry to group tab long-press menu --- .../io/nekohasekai/sagernet/ui/ConfigurationFragment.kt | 8 ++++++++ app/src/main/res/menu/group_tab_menu.xml | 3 +++ 2 files changed, 11 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 6b97a4ddf7..0871426d4d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1167,6 +1167,14 @@ class ConfigurationFragment @JvmOverloads constructor( popup.menuInflater.inflate(R.menu.group_tab_menu, popup.menu) popup.setOnMenuItemClickListener { item -> when (item.itemId) { + R.id.action_edit_group -> { + // 打开分组详情编辑页(名称/类型/订阅链接等) + startActivity(Intent(requireContext(), GroupSettingsActivity::class.java).apply { + putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) + }) + true + } + R.id.action_rename_group -> { renameGroup(group) true diff --git a/app/src/main/res/menu/group_tab_menu.xml b/app/src/main/res/menu/group_tab_menu.xml index d86ac00e3c..99a33b6012 100644 --- a/app/src/main/res/menu/group_tab_menu.xml +++ b/app/src/main/res/menu/group_tab_menu.xml @@ -1,5 +1,8 @@ + From d5257beb0eba4f5cb2592ef6950bac8bb519fa01 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 01:24:05 +0000 Subject: [PATCH 45/62] fix: address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 非法 DNS 地址降级为 local 并打日志,不再让服务起不来 - 国旗持久化缓存设 2000 条上限,超限清空自动重建 - DNS 策略统一到 dns.strategy,出站 domain_strategy 换成 domain_resolver(1.16 将移除的废弃字段) - 删除 1.12 遗留的 interfaceMonitorStub.MyInterface() 死代码 --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 29 ++++++++++++++----- .../nekohasekai/sagernet/ktx/CountryFlags.kt | 7 +++++ libcore/interface_monitor.go | 4 --- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index 2d26953853..3ae3b92707 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -30,6 +30,7 @@ import io.nekohasekai.sagernet.fmt.snell.SnellBean import io.nekohasekai.sagernet.fmt.snell.buildSingBoxOutboundSnellBean import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean +import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.mkPort import io.nekohasekai.sagernet.utils.PackageCache @@ -92,9 +93,14 @@ private fun DNSServerOptions.applyDnsAddress(address: String) { if (hasScheme && hostPart.isNotBlank()) interface_ = hostPart } "https", "h3" -> { - type = typeName val url = (if (hasScheme) value else "https://$value").toHttpUrlOrNull() - ?: throw Exception("Invalid DNS address: $address") + if (url == null) { + // 原版对坏地址更宽容:解析失败降级为 local,不让服务起不来 + Logs.d("invalid DNS address, fallback to local: $address") + type = "local" + return + } + type = typeName server = url.host if (url.port != 443) server_port = url.port path = url.encodedPath.takeIf { it.isNotBlank() && it != "/" } @@ -114,7 +120,10 @@ private fun DNSServerOptions.applyDnsAddress(address: String) { else -> server = hostPort } } - else -> throw Exception("Unsupported DNS address: $address") + else -> { + Logs.d("unsupported DNS address, fallback to local: $address") + type = "local" + } } } @@ -549,8 +558,10 @@ fun buildConfig( domainListDNSDirectForce.add("full:$serverAddress") } } - _hack_config_map["domain_strategy"] = - if (forTest) "" else defaultServerDomainStrategy + // 1.12+ 用 domain_resolver 替代已废弃的 domain_strategy + _hack_config_map["domain_resolver"] = + if (forTest || defaultServerDomainStrategy.isEmpty()) "dns-direct" + else mapOf("server" to "dns-direct", "strategy" to defaultServerDomainStrategy) _hack_config_map["tag"] = tagOut @@ -965,7 +976,6 @@ fun buildConfig( tag = "dns-direct" detour = TAG_DNS_DIRECT_OUT domain_resolver = "dns-local" - domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) } @@ -975,7 +985,6 @@ fun buildConfig( applyDnsAddress(it ?: throw Exception("No remote DNS, check your settings!")) tag = "dns-remote" domain_resolver = "dns-direct" - domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy(tag)) }) } if (dnsHosts.isNotEmpty()) { @@ -986,6 +995,11 @@ fun buildConfig( }) } + // 1.14 起 DNS server 不再支持独立 strategy(domain_strategy 已废弃),统一放到 dns.strategy + autoDnsDomainStrategy(genDomainStrategy(DataStore.resolveDestination))?.let { + dns.strategy = it + } + dns.final_ = if (forTest) "dns-direct" else "dns-remote" // dns object user rules @@ -1067,7 +1081,6 @@ fun buildConfig( if (!resolver.isIpAddress()) { domain_resolver = "dns-direct" } - domain_strategy = autoDnsDomainStrategy(SingBoxOptionsUtil.domainStrategy("server")) }) dns.rules.add(0, DNSRule_DefaultOptions().apply { makeSingBoxRule(hosts) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt index 180767ab38..3d1f868822 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt @@ -101,11 +101,18 @@ object CountryFlags { return "" } + private const val MAX_PERSISTED = 2000 + private fun remember(host: String, profileId: Long, code: String) { byHost[host] = code byProfile[profileId] = code failedAt.remove(host) try { + // 持久化条目设上限:超限整体清空,后续查询自动重建(条目很小,重建成本低) + if (prefs.all.size >= MAX_PERSISTED) { + prefs.edit().clear().apply() + byHost.clear() + } prefs.edit().putString(host, code).apply() } catch (ignored: Exception) { } diff --git a/libcore/interface_monitor.go b/libcore/interface_monitor.go index a04819ed46..9696f14250 100644 --- a/libcore/interface_monitor.go +++ b/libcore/interface_monitor.go @@ -43,7 +43,3 @@ func (s *interfaceMonitorStub) UnregisterCallback(element *list.Element[tun.Defa func (s *interfaceMonitorStub) RegisterMyInterface(interfaceName string) { } - -func (s *interfaceMonitorStub) MyInterface() string { - return "" -} From a1c48a574a38ee5f0285b38a2f8179290e62a2cf Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 01:35:05 +0000 Subject: [PATCH 46/62] feat: clear country flag cache together with node removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个删除场景现在都会同步清理对应节点的国旗缓存: 1. 主动删除节点(ProfileManager.deleteProfile / deleteProfile2,覆盖单删、多选、设置页) 2. 清理不可用配置(复用 deleteProfile2 入口) 3. 更新订阅时删除消失的节点(RawUpdater.toDelete) 4. 删除分组 / 清空分组(GroupManager.deleteGroup / clearGroup) 清理发生在删除动作之前,此时实体仍能取到服务器地址。 --- .../java/io/nekohasekai/sagernet/database/GroupManager.kt | 7 +++++++ .../io/nekohasekai/sagernet/database/ProfileManager.kt | 8 ++++++++ .../main/java/io/nekohasekai/sagernet/group/RawUpdater.kt | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt index b2688f498f..f73fd49fef 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.database import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.bg.SubscriptionUpdater +import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.applyDefaultValues object GroupManager { @@ -54,6 +55,8 @@ object GroupManager { } suspend fun clearGroup(groupId: Long) { + // 清空分组时同步清理该组全部节点的国旗缓存 + CountryFlags.forgetProfiles(SagerDatabase.proxyDao.getByGroup(groupId)) DataStore.selectedProxy = 0L SagerDatabase.proxyDao.deleteAll(groupId) iterator { groupUpdated(groupId) } @@ -98,6 +101,7 @@ object GroupManager { } suspend fun deleteGroup(groupId: Long) { + CountryFlags.forgetProfiles(SagerDatabase.proxyDao.getByGroup(groupId)) SagerDatabase.groupDao.deleteById(groupId) SagerDatabase.proxyDao.deleteByGroup(groupId) iterator { groupRemoved(groupId) } @@ -105,6 +109,9 @@ object GroupManager { } suspend fun deleteGroup(group: List) { + for (proxyGroup in group) { + CountryFlags.forgetProfiles(SagerDatabase.proxyDao.getByGroup(proxyGroup.id)) + } SagerDatabase.groupDao.deleteGroup(group) SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray()) for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt index 661db42492..383cb094d0 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -4,6 +4,7 @@ import android.database.sqlite.SQLiteCantOpenDatabaseException import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.fmt.AbstractBean +import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues @@ -108,6 +109,10 @@ object ProfileManager { } suspend fun deleteProfile2(groupId: Long, profileId: Long) { + // 删除节点时同步清理国旗缓存(订阅更新 / 清理不可用 / 多选 / 设置页都走这里) + SagerDatabase.proxyDao.getById(profileId)?.let { + CountryFlags.forgetProfiles(listOf(it)) + } if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L @@ -115,6 +120,9 @@ object ProfileManager { } suspend fun deleteProfile(groupId: Long, profileId: Long) { + SagerDatabase.proxyDao.getById(profileId)?.let { + CountryFlags.forgetProfiles(listOf(it)) + } if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index 1e1644f839..a9da4581af 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SubscriptionFilterMode import io.nekohasekai.sagernet.database.* +import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean @@ -232,6 +233,10 @@ object RawUpdater : GroupUpdater() { Logs.d("Updated profiles: $it") } + // 订阅更新删除的节点,同步清理国旗缓存(删除前实体还在,能取到服务器地址) + if (toDelete.isNotEmpty()) { + CountryFlags.forgetProfiles(toDelete) + } SagerDatabase.proxyDao.deleteProxy(toDelete).also { Logs.d("Deleted profiles: $it") } From 5b17b7318fc9e4499c78509156e9634666ef7775 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 02:32:51 +0000 Subject: [PATCH 47/62] feat: long-press group tab opens group settings directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 feat/profile-tools-v2 保持一致:弹出菜单移除,长按直接进入分组详情页(含改名与删除)。 --- .../sagernet/ui/ConfigurationFragment.kt | 104 ++---------------- app/src/main/res/menu/group_tab_menu.xml | 12 -- 2 files changed, 7 insertions(+), 109 deletions(-) delete mode 100644 app/src/main/res/menu/group_tab_menu.xml diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 0871426d4d..69775be0f7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -19,8 +19,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup -import android.widget.EditText -import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -357,8 +355,13 @@ class ConfigurationFragment @JvmOverloads constructor( val group = adapter.groupList.getOrNull(position) if (group != null) { tab.text = group.displayName() - tab.view.setOnLongClickListener { anchor -> - showGroupTabMenu(anchor, group) + tab.view.setOnLongClickListener { + // 长按直接进入分组详情编辑页(名称/类型/订阅链接,含改名与删除) + if (!group.ungrouped) { + startActivity(Intent(requireContext(), GroupSettingsActivity::class.java).apply { + putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) + }) + } true } } else { @@ -1158,99 +1161,6 @@ class ConfigurationFragment @JvmOverloads constructor( } } - // ---------------- 分组名长按菜单 ---------------- - - private fun showGroupTabMenu(anchor: View, group: ProxyGroup) { - // 「未分组」是系统分组,不提供改名/删除 - if (group.ungrouped) return - val popup = PopupMenu(requireContext(), anchor) - popup.menuInflater.inflate(R.menu.group_tab_menu, popup.menu) - popup.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.action_edit_group -> { - // 打开分组详情编辑页(名称/类型/订阅链接等) - startActivity(Intent(requireContext(), GroupSettingsActivity::class.java).apply { - putExtra(GroupSettingsActivity.EXTRA_GROUP_ID, group.id) - }) - true - } - - R.id.action_rename_group -> { - renameGroup(group) - true - } - - R.id.action_delete_group -> { - confirmDeleteGroup(group) - true - } - - else -> false - } - } - popup.show() - } - - private fun renameGroup(group: ProxyGroup) { - val input = EditText(requireContext()).apply { - setText(group.name ?: "") - setSelection(text.length) - hint = getString(R.string.group_name) - setSingleLine() - } - val container = FrameLayout(requireContext()).apply { - val padding = (20 * resources.displayMetrics.density).toInt() - setPadding(padding, padding / 2, padding, 0) - addView(input) - } - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.rename_group) - .setView(container) - .setPositiveButton(android.R.string.ok) { _, _ -> - val newName = input.text?.toString()?.trim().orEmpty() - if (newName.isEmpty()) { - snackbar(getString(R.string.group_name_required)).show() - return@setPositiveButton - } - runOnDefaultDispatcher { - try { - group.name = newName - GroupManager.updateGroup(group) - } catch (e: Exception) { - Logs.w(e) - onMainDispatcher { snackbar(e.readableMessage).show() } - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun confirmDeleteGroup(group: ProxyGroup) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.delete_group) - .setMessage("${getString(R.string.delete_group_prompt)}\n\n${group.displayName()}") - .setPositiveButton(R.string.yes) { _, _ -> - runOnDefaultDispatcher { - try { - GroupManager.deleteGroup(group.id) - } catch (e: Exception) { - Logs.w(e) - onMainDispatcher { snackbar(e.readableMessage).show() } - return@runOnDefaultDispatcher - } - onMainDispatcher { - if (DataStore.selectedGroup == group.id) { - DataStore.selectedGroup = -1L - } - adapter.reload() - } - } - } - .setNegativeButton(R.string.no, null) - .show() - } - // ---------------- 多选 & 移动分组 ---------------- fun isSelectionMode(): Boolean = getCurrentGroupFragment()?.adapter?.selectionMode == true diff --git a/app/src/main/res/menu/group_tab_menu.xml b/app/src/main/res/menu/group_tab_menu.xml deleted file mode 100644 index 99a33b6012..0000000000 --- a/app/src/main/res/menu/group_tab_menu.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - From 082c1ea37b6d44254392918213446a2d93868dc5 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 08:55:33 +0000 Subject: [PATCH 48/62] fix: stop emitting vless encryption field that sing-box 1.14 rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机场的 Clash 订阅带 encryption: mlkem768x25519plus(VLESS Encryption)时, 配置生成会把 encryption 写进 outbound JSON。经实测 sing-box 1.14 的 VLESSOutboundOptions 没有该字段(strict JSON 直接 FATAL unknown field), 整个服务无法启动——比节点超时更严重。 sing-box 到 1.15.0-alpha.13 都未实现 VLESS Encryption(mihomo/Xray 能力)。 解析(Clash YAML / vless:// 链接 / 手动编辑)、存储、分享链接全部保留, 仅不再把该字段写入内核配置;内核未来支持后恢复一行赋值即可。 --- .../main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt index 80d125400d..e8c698c5d4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt @@ -842,9 +842,9 @@ fun buildSingBoxOutboundStandardV2RayBean(bean: StandardV2RayBean): Outbound { if (bean.encryption.isNotBlank() && bean.encryption != "auto") { flow = bean.encryption } - if (bean.vlessEncryption.isNotBlank() && bean.vlessEncryption != "none") { - encryption = bean.vlessEncryption - } + // sing-box 1.14 的 vless outbound 没有 encryption 字段(VLESS Encryption + // 是 mihomo/Xray 生态能力),输出会导致 decode config: unknown field, + // 整个服务无法启动。解析与存储保留(分享链接仍带 encryption=)。 when (bean.packetEncoding) { 0 -> packet_encoding = "" 1 -> packet_encoding = "packetaddr" From 65ebf07d373871c06fd10ba600dd9372ddf90f59 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 10:21:06 +0000 Subject: [PATCH 49/62] feat: port VLESS Encryption (mlkem768x25519plus) into the kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机场的 Clash 订阅普遍下发带 encryption 的 VLESS 节点,而 sing-box 官方 (1.14 / 1.15.0-alpha.13 / dev-next)至今未实现该协议层加密。 实现从 qingwan 的 1.15 定制内核移植(约 900 行纯 Go,无第三方依赖): - protocol/vless/lx_encryption.go 配置解析 + 握手包装(超时/取消保护) - protocol/vless/encryption/*.go 客户端实现(ML-KEM-768 + X25519) - option/vless.go 新增 encryption 字段 - protocol/vless/outbound.go 初始化与两处拨号接线 通过 patch 脚本注入官方源码,保持幂等、锚点失配即报错。 App 侧恢复 outbound encryption 字段输出(之前因内核不认而移除以避免 FATAL)。 已验证:干净 1.14 源码 + patch 脚本 → go build ./protocol/vless/... 通过。 --- .../sagernet/fmt/v2ray/V2RayFmt.kt | 8 +- buildScript/lib/core/patch-singbox.py | 122 +++++++ .../encryption/client.go | 223 +++++++++++++ .../encryption/common.go | 298 ++++++++++++++++++ .../encryption/lx_support.go | 36 +++ .../encryption/lx_vision.go | 38 +++ .../encryption/xor.go | 103 ++++++ .../singbox-vless-encryption/lx_encryption.go | 207 ++++++++++++ 8 files changed, 1032 insertions(+), 3 deletions(-) create mode 100644 buildScript/lib/core/singbox-vless-encryption/encryption/client.go create mode 100644 buildScript/lib/core/singbox-vless-encryption/encryption/common.go create mode 100644 buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go create mode 100644 buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go create mode 100644 buildScript/lib/core/singbox-vless-encryption/encryption/xor.go create mode 100644 buildScript/lib/core/singbox-vless-encryption/lx_encryption.go diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt index e8c698c5d4..b1eebb44d9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt @@ -842,9 +842,11 @@ fun buildSingBoxOutboundStandardV2RayBean(bean: StandardV2RayBean): Outbound { if (bean.encryption.isNotBlank() && bean.encryption != "auto") { flow = bean.encryption } - // sing-box 1.14 的 vless outbound 没有 encryption 字段(VLESS Encryption - // 是 mihomo/Xray 生态能力),输出会导致 decode config: unknown field, - // 整个服务无法启动。解析与存储保留(分享链接仍带 encryption=)。 + // 内核已通过 patch 支持 VLESS Encryption(mlkem768x25519plus,见 + // buildScript/lib/core/patch-singbox.py),机场的 Clash 订阅可正常导入使用 + if (bean.vlessEncryption.isNotBlank() && bean.vlessEncryption != "none") { + encryption = bean.vlessEncryption + } when (bean.packetEncoding) { 0 -> packet_encoding = "" 1 -> packet_encoding = "packetaddr" diff --git a/buildScript/lib/core/patch-singbox.py b/buildScript/lib/core/patch-singbox.py index 4dd826b10d..7d87724504 100644 --- a/buildScript/lib/core/patch-singbox.py +++ b/buildScript/lib/core/patch-singbox.py @@ -60,6 +60,35 @@ ''' + +VLESS_ENCRYPTION_DIR = pathlib.Path(__file__).resolve().parent / "singbox-vless-encryption" + +VLESS_OPTION_FIELD = ( + "\t// Encryption 是 VLESS 内部的后量子加密层(mlkem768x25519plus),\n" + "\t// 位于传输层之下、与 TLS/REALITY 无关。空或 \"none\" 表示关闭。\n" + "\tEncryption string `json:\"encryption,omitempty\"`\n" +) + +VLESS_FIELD_BLOCK = ( + "\t// VLESS 后量子加密层(mlkem768x25519plus),未配置时为 nil\n" + "\tencryption *encryption.ClientInstance\n" +) + +VLESS_NEW_BLOCK = ( + "\t// VLESS Encryption:层在 VLESS 内部、传输之下,与 TLS/REALITY 无关\n" + "\tif options.Encryption != \"\" && options.Encryption != \"none\" {\n" + "\t\tencryptionConfig, err := parseClientEncryption(options.Encryption)\n" + "\t\tif err != nil {\n" + "\t\t\treturn nil, E.Cause(err, \"parse encryption\")\n" + "\t\t}\n" + "\t\toutbound.encryption = &encryption.ClientInstance{}\n" + "\t\tif err := outbound.encryption.Init(encryptionConfig.keys, encryptionConfig.xorMode, encryptionConfig.seconds, encryptionConfig.padding); err != nil {\n" + "\t\t\treturn nil, E.Cause(err, \"initialize encryption\")\n" + "\t\t}\n" + "\t}\n" +) + + def fail(message: str) -> None: print("patch-singbox: " + message, file=sys.stderr) sys.exit(1) @@ -121,6 +150,98 @@ def patch_selector(root: pathlib.Path) -> None: print("patch-singbox: selector.go patched") + +def patch_vless_encryption(root: pathlib.Path) -> None: + """移植 VLESS Encryption(mlkem768x25519plus,来自 qingwan 的 1.15 定制内核)。 + + 上游 sing-box(含 1.15.0-alpha.13 / dev-next)至今未实现该特性, + 这是 Xray 2025 引入的协议层后量子加密。实现文件为纯 Go 无第三方依赖。 + """ + if not VLESS_ENCRYPTION_DIR.is_dir(): + fail("找不到 singbox-vless-encryption 目录,仓库不完整") + + # 1) 拷贝实现文件 + vless_dir = root / "protocol" / "vless" + if not vless_dir.is_dir(): + fail("protocol/vless 目录不存在,上游结构可能已变化") + enc_dir = vless_dir / "encryption" + enc_dir.mkdir(parents=True, exist_ok=True) + (vless_dir / "lx_encryption.go").write_bytes( + (VLESS_ENCRYPTION_DIR / "lx_encryption.go").read_bytes() + ) + for src in sorted((VLESS_ENCRYPTION_DIR / "encryption").glob("*.go")): + (enc_dir / src.name).write_bytes(src.read_bytes()) + + # 2) option/vless.go:新增 encryption 字段 + path = root / "option" / "vless.go" + text = path.read_text(encoding="utf-8") + if 'json:"encryption,omitempty"' in text: + print("patch-singbox: option/vless.go 已打过补丁,跳过") + else: + anchor = '\tNetwork NetworkList `json:"network,omitempty"`\n' + if anchor not in text: + fail("option/vless.go 锚点未匹配,上游结构可能已变化") + text = text.replace(anchor, anchor + VLESS_OPTION_FIELD, 1) + path.write_text(text, encoding="utf-8") + print("patch-singbox: option/vless.go patched") + + # 3) protocol/vless/outbound.go:结构体字段 / 初始化 / 两处握手接线 + path = root / "protocol" / "vless" / "outbound.go" + text = path.read_text(encoding="utf-8") + if "wrapEncryption" in text: + print("patch-singbox: protocol/vless/outbound.go 已打过补丁,跳过") + return + + import_anchor = '\t"github.com/sagernet/sing-box/option"\n' + if import_anchor not in text: + fail("outbound.go 找不到 option import 锚点") + text = text.replace( + import_anchor, + import_anchor + '\t"github.com/sagernet/sing-box/protocol/vless/encryption"\n', + 1, + ) + + field_anchor = "\tpacketAddr bool\n\txudp bool\n" + if field_anchor not in text: + fail("outbound.go 结构体锚点未匹配,上游结构可能已变化") + text = text.replace(field_anchor, field_anchor + VLESS_FIELD_BLOCK, 1) + + new_anchor = ( + "\toutbound.multiplexDialer, err = mux.NewClientWithOptions(" + "(*vlessDialer)(outbound), logger, common.PtrValueOrDefault(options.Multiplex))" + ) + if new_anchor not in text: + fail("outbound.go NewOutbound 锚点未匹配,上游结构可能已变化") + text = text.replace(new_anchor, VLESS_NEW_BLOCK + new_anchor, 1) + + dial_anchor = "\tif err != nil {\n\t\treturn nil, err\n\t}\n\tswitch N.NetworkName(network) {" + if dial_anchor not in text: + fail("outbound.go DialContext 锚点未匹配,上游结构可能已变化") + text = text.replace( + dial_anchor, + "\tif err != nil {\n\t\treturn nil, err\n\t}\n" + "\tconn, err = h.wrapEncryption(ctx, conn)\n" + "\tif err != nil {\n\t\treturn nil, err\n\t}\n" + "\tswitch N.NetworkName(network) {", + 1, + ) + + packet_anchor = "\tif err != nil {\n\t\tcommon.Close(conn)\n\t\treturn nil, err\n\t}\n\tif h.xudp {" + if packet_anchor not in text: + fail("outbound.go ListenPacket 锚点未匹配,上游结构可能已变化") + text = text.replace( + packet_anchor, + "\tif err != nil {\n\t\tcommon.Close(conn)\n\t\treturn nil, err\n\t}\n" + "\tconn, err = h.wrapEncryption(ctx, conn)\n" + "\tif err != nil {\n\t\tcommon.Close(conn)\n\t\treturn nil, err\n\t}\n" + "\tif h.xudp {", + 1, + ) + + path.write_text(text, encoding="utf-8") + print("patch-singbox: protocol/vless/outbound.go patched") + + def main() -> None: if len(sys.argv) < 2: fail("缺少 sing-box 源码目录参数") @@ -135,6 +256,7 @@ def main() -> None: patch_rule_set_local(root) patch_selector(root) + patch_vless_encryption(root) print("patch-singbox: done") diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/client.go b/buildScript/lib/core/singbox-vless-encryption/encryption/client.go new file mode 100644 index 0000000000..edca768901 --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/client.go @@ -0,0 +1,223 @@ +// Package encryption implements the VLESS `encryption` layer +// (mlkem768x25519plus) — a post-quantum handshake that sits inside VLESS, +// beneath the transport and independent of TLS. +// +// lx: ported from the sing-box fork at github.com/starifly/sing-box +// (protocol/vless/encryption), which carries the same GPL-3.0 license and the +// same upstream base as this tree. Client side only: this fork is +// client-focused, so the server half (`decryption`) was deliberately not taken. +// Local helpers that replace its common/xray/{cpuid,crypto} imports live in +// lx_support.go. See SPECS/TASKS/032-VLESS_ENCRYPTION_MLKEM768. +package encryption + +import ( + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "io" + "net" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "lukechampine.com/blake3" +) + +type ClientInstance struct { + NfsPKeys []any + NfsPKeysBytes [][]byte + Hash32s [][32]byte + RelaysLength int + XorMode uint32 + Seconds uint32 + PaddingLens [][3]int + PaddingGaps [][3]int + + RWLock sync.RWMutex + Expire time.Time + PfsKey []byte + Ticket []byte +} + +func (i *ClientInstance) Init(nfsPKeysBytes [][]byte, xorMode, seconds uint32, padding string) (err error) { + if i.NfsPKeys != nil { + return E.New("already initialized") + } + l := len(nfsPKeysBytes) + if l == 0 { + return E.New("empty nfsPKeysBytes") + } + i.NfsPKeys = make([]any, l) + i.NfsPKeysBytes = nfsPKeysBytes + i.Hash32s = make([][32]byte, l) + for j, k := range nfsPKeysBytes { + if len(k) == 32 { + if i.NfsPKeys[j], err = ecdh.X25519().NewPublicKey(k); err != nil { + return + } + i.RelaysLength += 32 + 32 + } else { + if i.NfsPKeys[j], err = mlkem.NewEncapsulationKey768(k); err != nil { + return + } + i.RelaysLength += 1088 + 32 + } + i.Hash32s[j] = blake3.Sum256(k) + } + i.RelaysLength -= 32 + i.XorMode = xorMode + i.Seconds = seconds + return ParsePadding(padding, &i.PaddingLens, &i.PaddingGaps) +} + +func (i *ClientInstance) IsFullRandomXorMode() bool { + return i.XorMode == 2 +} + +func (i *ClientInstance) Handshake(conn net.Conn) (*CommonConn, error) { + if i.NfsPKeys == nil { + return nil, E.New("uninitialized") + } + c := NewCommonConn(conn, hasAESGCMHardware) + + ivAndRealysLength := 16 + i.RelaysLength + pfsKeyExchangeLength := 18 + 1184 + 32 + 16 + paddingLength, paddingLens, paddingGaps := CreatePadding(i.PaddingLens, i.PaddingGaps) + clientHello := make([]byte, ivAndRealysLength+pfsKeyExchangeLength+paddingLength) + + iv := clientHello[:16] + rand.Read(iv) + relays := clientHello[16:ivAndRealysLength] + var nfsKey []byte + var lastCTR cipher.Stream + for j, k := range i.NfsPKeys { + index := 32 + if k, ok := k.(*ecdh.PublicKey); ok { + privateKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + copy(relays, privateKey.PublicKey().Bytes()) + var err error + nfsKey, err = privateKey.ECDH(k) + if err != nil { + return nil, err + } + } + if k, ok := k.(*mlkem.EncapsulationKey768); ok { + var ciphertext []byte + nfsKey, ciphertext = k.Encapsulate() + copy(relays, ciphertext) + index = 1088 + } + if i.XorMode > 0 { // this xor can (others can't) be recovered by client's config, revealing an X25519 public key / ML-KEM-768 ciphertext, that's why "native" values + NewCTR(i.NfsPKeysBytes[j], iv).XORKeyStream(relays, relays[:index]) // make X25519 public key / ML-KEM-768 ciphertext distinguishable from random bytes + } + if lastCTR != nil { + lastCTR.XORKeyStream(relays, relays[:32]) // make this relay irreplaceable + } + if j == len(i.NfsPKeys)-1 { + break + } + lastCTR = NewCTR(nfsKey, iv) + lastCTR.XORKeyStream(relays[index:], i.Hash32s[j+1][:]) + relays = relays[index+32:] + } + nfsAEAD := NewAEAD(iv, nfsKey, c.UseAES) + + if i.Seconds > 0 { + i.RWLock.RLock() + if time.Now().Before(i.Expire) { + c.Client = i + c.UnitedKey = append(i.PfsKey, nfsKey...) // different unitedKey for each connection + nfsAEAD.Seal(clientHello[:ivAndRealysLength], nil, EncodeLength(32), nil) + nfsAEAD.Seal(clientHello[:ivAndRealysLength+18], nil, i.Ticket, nil) + i.RWLock.RUnlock() + c.PreWrite = clientHello[:ivAndRealysLength+18+32] + c.AEAD = NewAEAD(clientHello[ivAndRealysLength+18:ivAndRealysLength+18+32], c.UnitedKey, c.UseAES) + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), nil, len(c.PreWrite), 16) + } + return c, nil + } + i.RWLock.RUnlock() + } + + pfsKeyExchange := clientHello[ivAndRealysLength : ivAndRealysLength+pfsKeyExchangeLength] + nfsAEAD.Seal(pfsKeyExchange[:0], nil, EncodeLength(pfsKeyExchangeLength-18), nil) + mlkem768DKey, _ := mlkem.GenerateKey768() + x25519SKey, _ := ecdh.X25519().GenerateKey(rand.Reader) + pfsPublicKey := append(mlkem768DKey.EncapsulationKey().Bytes(), x25519SKey.PublicKey().Bytes()...) + nfsAEAD.Seal(pfsKeyExchange[:18], nil, pfsPublicKey, nil) + + padding := clientHello[ivAndRealysLength+pfsKeyExchangeLength:] + nfsAEAD.Seal(padding[:0], nil, EncodeLength(paddingLength-18), nil) + nfsAEAD.Seal(padding[:18], nil, padding[18:paddingLength-16], nil) + + paddingLens[0] = ivAndRealysLength + pfsKeyExchangeLength + paddingLens[0] + for i, l := range paddingLens { // sends padding in a fragmented way, to create variable traffic pattern, before inner VLESS flow takes control + if l > 0 { + if _, err := conn.Write(clientHello[:l]); err != nil { + return nil, err + } + clientHello = clientHello[l:] + } + if len(paddingGaps) > i { + time.Sleep(paddingGaps[i]) + } + } + + encryptedPfsPublicKey := make([]byte, 1088+32+16) + if _, err := io.ReadFull(conn, encryptedPfsPublicKey); err != nil { + return nil, err + } + nfsAEAD.Open(encryptedPfsPublicKey[:0], MaxNonce, encryptedPfsPublicKey, nil) + mlkem768Key, err := mlkem768DKey.Decapsulate(encryptedPfsPublicKey[:1088]) + if err != nil { + return nil, err + } + peerX25519PKey, err := ecdh.X25519().NewPublicKey(encryptedPfsPublicKey[1088 : 1088+32]) + if err != nil { + return nil, err + } + x25519Key, err := x25519SKey.ECDH(peerX25519PKey) + if err != nil { + return nil, err + } + pfsKey := make([]byte, 32+32) // no more capacity + copy(pfsKey, mlkem768Key) + copy(pfsKey[32:], x25519Key) + c.UnitedKey = append(pfsKey, nfsKey...) + c.AEAD = NewAEAD(pfsPublicKey, c.UnitedKey, c.UseAES) + c.PeerAEAD = NewAEAD(encryptedPfsPublicKey[:1088+32], c.UnitedKey, c.UseAES) + + encryptedTicket := make([]byte, 32) + if _, err := io.ReadFull(conn, encryptedTicket); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedTicket[:0], nil, encryptedTicket, nil); err != nil { + return nil, err + } + seconds := DecodeLength(encryptedTicket) + + if i.Seconds > 0 && seconds > 0 { + i.RWLock.Lock() + i.Expire = time.Now().Add(time.Duration(seconds) * time.Second) + i.PfsKey = pfsKey + i.Ticket = encryptedTicket[:16] + i.RWLock.Unlock() + } + + encryptedLength := make([]byte, 18) + if _, err := io.ReadFull(conn, encryptedLength); err != nil { + return nil, err + } + if _, err := c.PeerAEAD.Open(encryptedLength[:0], nil, encryptedLength, nil); err != nil { + return nil, err + } + length := DecodeLength(encryptedLength[:2]) + c.PeerPadding = make([]byte, length) // important: allows server sends padding slowly, eliminating 1-RTT's traffic pattern + + if i.XorMode == 2 { + c.Conn = NewXorConn(conn, NewCTR(c.UnitedKey, iv), NewCTR(c.UnitedKey, encryptedTicket[:16]), 0, length) + } + return c, nil +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/common.go b/buildScript/lib/core/singbox-vless-encryption/encryption/common.go new file mode 100644 index 0000000000..a5dab0325a --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/common.go @@ -0,0 +1,298 @@ +// lx: ported from github.com/starifly/sing-box (GPL-3.0, same upstream base). +// See SPECS/TASKS/032-VLESS_ENCRYPTION_MLKEM768. +package encryption + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" + "golang.org/x/crypto/chacha20poly1305" + "lukechampine.com/blake3" +) + +var OutBytesPool = sync.Pool{ + New: func() any { + return make([]byte, 5+8192+16) + }, +} + +type EncryptionConn interface { + net.Conn + IsEncryptionLayer() bool +} + +type CommonConn struct { + net.Conn + UseAES bool + Client *ClientInstance + UnitedKey []byte + PreWrite []byte + AEAD *AEAD + PeerAEAD *AEAD + PeerPadding []byte + rawInput bytes.Buffer + input bytes.Reader +} + +func NewCommonConn(conn net.Conn, useAES bool) *CommonConn { + return &CommonConn{ + Conn: conn, + UseAES: useAES, + } +} + +func (c *CommonConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + outBytes := OutBytesPool.Get().([]byte) + defer OutBytesPool.Put(outBytes) + for n := 0; n < len(b); { + b := b[n:] + if len(b) > 8192 { + b = b[:8192] // for avoiding another copy() in peer's Read() + } + n += len(b) + headerAndData := outBytes[:5+len(b)+16] + EncodeHeader(headerAndData, len(b)+16) + max := false + if bytes.Equal(c.AEAD.Nonce[:], MaxNonce) { + max = true + } + c.AEAD.Seal(headerAndData[:5], nil, b, headerAndData[:5]) + if max { + c.AEAD = NewAEAD(headerAndData, c.UnitedKey, c.UseAES) + } + if c.PreWrite != nil { + headerAndData = append(c.PreWrite, headerAndData...) + c.PreWrite = nil + } + if _, err := c.Conn.Write(headerAndData); err != nil { + return 0, err + } + } + return len(b), nil +} + +func (c *CommonConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + if c.PeerAEAD == nil { // client's 0-RTT + serverRandom := make([]byte, 16) + if _, err := io.ReadFull(c.Conn, serverRandom); err != nil { + return 0, err + } + c.PeerAEAD = NewAEAD(serverRandom, c.UnitedKey, c.UseAES) + if xorConn, ok := c.Conn.(*XorConn); ok { + xorConn.PeerCTR = NewCTR(c.UnitedKey, serverRandom) + } + } + if c.PeerPadding != nil { // client's 1-RTT + if _, err := io.ReadFull(c.Conn, c.PeerPadding); err != nil { + return 0, err + } + if _, err := c.PeerAEAD.Open(c.PeerPadding[:0], nil, c.PeerPadding, nil); err != nil { + return 0, err + } + c.PeerPadding = nil + } + if c.input.Len() > 0 { + return c.input.Read(b) + } + peerHeader := [5]byte{} + if _, err := io.ReadFull(c.Conn, peerHeader[:]); err != nil { + return 0, err + } + l, err := DecodeHeader(peerHeader[:]) // l: 17~17000 + if err != nil { + if c.Client != nil && errors.Is(err, ErrInvalidHeader) { // client's 0-RTT + c.Client.RWLock.Lock() + if bytes.HasPrefix(c.UnitedKey, c.Client.PfsKey) { + c.Client.Expire = time.Now() // expired + } + c.Client.RWLock.Unlock() + return 0, E.New("new handshake needed") + } + return 0, err + } + c.Client = nil + if c.rawInput.Cap() < l { + c.rawInput.Grow(l) // no need to use sync.Pool, because we are always reading + } + peerData := c.rawInput.Bytes()[:l] + if _, err := io.ReadFull(c.Conn, peerData); err != nil { + return 0, err + } + dst := peerData[:l-16] + if len(dst) <= len(b) { + dst = b[:len(dst)] // avoids another copy() + } + var newAEAD *AEAD + if bytes.Equal(c.PeerAEAD.Nonce[:], MaxNonce) { + newAEAD = NewAEAD(append(peerHeader[:], peerData...), c.UnitedKey, c.UseAES) + } + _, err = c.PeerAEAD.Open(dst[:0], nil, peerData, peerHeader[:]) + if newAEAD != nil { + c.PeerAEAD = newAEAD + } + if err != nil { + return 0, err + } + if len(dst) > len(b) { + c.input.Reset(dst[copy(b, dst):]) + dst = b // for len(dst) + } + return len(dst), nil +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *CommonConn) Upstream() any { + return c.Conn +} + +func (c *CommonConn) IsEncryptionLayer() bool { + return true +} + +type AEAD struct { + cipher.AEAD + Nonce [12]byte +} + +func NewAEAD(ctx, key []byte, useAES bool) *AEAD { + k := make([]byte, 32) + blake3.DeriveKey(k, string(ctx), key) + var aead cipher.AEAD + if useAES { + block, _ := aes.NewCipher(k) + aead, _ = cipher.NewGCM(block) + } else { + aead, _ = chacha20poly1305.New(k) + } + return &AEAD{AEAD: aead} +} + +func (a *AEAD) Seal(dst, nonce, plaintext, additionalData []byte) []byte { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Seal(dst, nonce, plaintext, additionalData) +} + +func (a *AEAD) Open(dst, nonce, ciphertext, additionalData []byte) ([]byte, error) { + if nonce == nil { + nonce = IncreaseNonce(a.Nonce[:]) + } + return a.AEAD.Open(dst, nonce, ciphertext, additionalData) +} + +func IncreaseNonce(nonce []byte) []byte { + for i := range 12 { + nonce[11-i]++ + if nonce[11-i] != 0 { + break + } + } + return nonce +} + +var MaxNonce = bytes.Repeat([]byte{255}, 12) + +func EncodeLength(l int) []byte { + return []byte{byte(l >> 8), byte(l)} +} + +func DecodeLength(b []byte) int { + return int(b[0])<<8 | int(b[1]) +} + +func EncodeHeader(h []byte, l int) { + h[0] = 23 + h[1] = 3 + h[2] = 3 + h[3] = byte(l >> 8) + h[4] = byte(l) +} + +var ErrInvalidHeader = errors.New("invalid header") + +func DecodeHeader(h []byte) (l int, err error) { + l = int(h[3])<<8 | int(h[4]) + if h[0] != 23 || h[1] != 3 || h[2] != 3 { + l = 0 + } + if l < 17 || l > 17000 { // TODO: TLSv1.3 max length + err = fmt.Errorf("%w: %v", ErrInvalidHeader, h[:5]) // DO NOT CHANGE: relied by client's Read() + } + return +} + +func ParsePadding(padding string, paddingLens, paddingGaps *[][3]int) (err error) { + if padding == "" { + return + } + maxLen := 0 + for i, s := range strings.Split(padding, ".") { + x := strings.Split(s, "-") + if len(x) < 3 || x[0] == "" || x[1] == "" || x[2] == "" { + return E.New("invalid padding lenth/gap parameter: " + s) + } + y := [3]int{} + if y[0], err = strconv.Atoi(x[0]); err != nil { + return + } + if y[1], err = strconv.Atoi(x[1]); err != nil { + return + } + if y[2], err = strconv.Atoi(x[2]); err != nil { + return + } + if i == 0 && (y[0] < 100 || y[1] < 18+17 || y[2] < 18+17) { + return E.New("first padding length must not be smaller than 35") + } + if i%2 == 0 { + *paddingLens = append(*paddingLens, y) + maxLen += max(y[1], y[2]) + } else { + *paddingGaps = append(*paddingGaps, y) + } + } + if maxLen > 18+65535 { + return E.New("total padding length must not be larger than 65553") + } + return +} + +func CreatePadding(paddingLens, paddingGaps [][3]int) (length int, lens []int, gaps []time.Duration) { + if len(paddingLens) == 0 { + paddingLens = [][3]int{{100, 111, 1111}, {50, 0, 3333}} + paddingGaps = [][3]int{{75, 0, 111}} + } + for _, y := range paddingLens { + l := 0 + if y[0] >= int(randBetween(0, 100)) { + l = int(randBetween(int64(y[1]), int64(y[2]))) + } + lens = append(lens, l) + length += l + } + for _, y := range paddingGaps { + g := 0 + if y[0] >= int(randBetween(0, 100)) { + g = int(randBetween(int64(y[1]), int64(y[2]))) + } + gaps = append(gaps, time.Duration(g)*time.Millisecond) + } + return +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go new file mode 100644 index 0000000000..f32624ff5e --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_support.go @@ -0,0 +1,36 @@ +package encryption + +// lx: local replacements for the two tiny helpers the upstream reference kept in +// its own `common/xray/{cpuid,crypto}` packages. Vendoring those wholesale would +// drag in a large Xray-compat tree for ~20 lines of logic, so the pieces this +// package actually uses live here instead. + +import ( + "crypto/rand" + "math/big" + "runtime" + + "golang.org/x/sys/cpu" +) + +// hasAESGCMHardware reports whether the CPU accelerates AES-GCM. The handshake +// picks AES-GCM over ChaCha20-Poly1305 when it does, matching what the peer +// expects to negotiate. Kept in sync with crypto/tls/cipher_suites.go. +var hasAESGCMHardware = (cpu.X86.HasAES && cpu.X86.HasPCLMULQDQ) || + (cpu.ARM64.HasAES && cpu.ARM64.HasPMULL) || + (cpu.S390X.HasAES && cpu.S390X.HasAESCBC && cpu.S390X.HasGHASH) || + runtime.GOARCH == "ppc64" || runtime.GOARCH == "ppc64le" + +// randBetween returns a uniform random value in [from, to). Used for the +// padding/delay ranges, which are cosmetic on the wire but must not be +// predictable, hence crypto/rand rather than math/rand. +func randBetween(from int64, to int64) int64 { + if from == to { + return from + } + if from > to { + from, to = to, from + } + bigInt, _ := rand.Int(rand.Reader, big.NewInt(to-from)) + return from + bigInt.Int64() +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go new file mode 100644 index 0000000000..7374a86d16 --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/lx_vision.go @@ -0,0 +1,38 @@ +package encryption + +// lx: SPEC 105 — let flow `xtls-rprx-vision` run on top of this layer. +// +// Vision in sing-vmess finds the TLS conn beneath it through a private registry +// that only knows crypto/tls and utls, so a *CommonConn was rejected with +// "not a valid supported TLS connection". Xray treats CommonConn like a TLS +// conn: Vision reads its input/rawInput fields, and direct copy goes to the +// conn beneath the encryption layer (proxy.UnwrapRawConn). One registry entry +// gives sing-vmess the same view. + +import ( + "net" + "reflect" + "unsafe" + + "github.com/sagernet/sing-vmess/vless" + N "github.com/sagernet/sing/common/network" +) + +// Importing vless above runs its init first, so the entry lands after the +// built-in crypto/tls and utls ones. +var _ = vless.FlowVision + +//go:linkname visionTLSRegistry github.com/sagernet/sing-vmess/vless.tlsRegistry +var visionTLSRegistry []func(conn net.Conn) (loaded bool, netConn net.Conn, reflectType reflect.Type, reflectPointer uintptr) + +func init() { + visionTLSRegistry = append(visionTLSRegistry, castVisionConn) +} + +func castVisionConn(conn net.Conn) (loaded bool, netConn net.Conn, reflectType reflect.Type, reflectPointer uintptr) { + commonConn, loaded := N.CastReader[*CommonConn](conn) + if !loaded { + return + } + return true, commonConn.Conn, reflect.TypeOf(commonConn).Elem(), uintptr(unsafe.Pointer(commonConn)) +} diff --git a/buildScript/lib/core/singbox-vless-encryption/encryption/xor.go b/buildScript/lib/core/singbox-vless-encryption/encryption/xor.go new file mode 100644 index 0000000000..074677a85e --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/encryption/xor.go @@ -0,0 +1,103 @@ +// lx: ported from github.com/starifly/sing-box (GPL-3.0, same upstream base). +// See SPECS/TASKS/032-VLESS_ENCRYPTION_MLKEM768. +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "net" + + "lukechampine.com/blake3" +) + +func NewCTR(key, iv []byte) cipher.Stream { + k := make([]byte, 32) + blake3.DeriveKey(k, "VLESS", key) // avoids using key directly + block, _ := aes.NewCipher(k) + return cipher.NewCTR(block, iv) +} + +type XorConn struct { + net.Conn + CTR cipher.Stream + PeerCTR cipher.Stream + OutSkip int + OutHeader []byte + InSkip int + InHeader []byte +} + +func NewXorConn(conn net.Conn, ctr, peerCTR cipher.Stream, outSkip, inSkip int) *XorConn { + return &XorConn{ + Conn: conn, + CTR: ctr, + PeerCTR: peerCTR, + OutSkip: outSkip, + OutHeader: make([]byte, 0, 5), // important + InSkip: inSkip, + InHeader: make([]byte, 0, 5), // important + } +} + +func (c *XorConn) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + for p := b; ; { + if len(p) <= c.OutSkip { + c.OutSkip -= len(p) + break + } + p = p[c.OutSkip:] + c.OutSkip = 0 + need := 5 - len(c.OutHeader) + if len(p) < need { + c.OutHeader = append(c.OutHeader, p...) + c.CTR.XORKeyStream(p, p) + break + } + c.OutSkip, _ = DecodeHeader(append(c.OutHeader, p[:need]...)) + c.OutHeader = c.OutHeader[:0] + c.CTR.XORKeyStream(p[:need], p[:need]) + p = p[need:] + } + if _, err := c.Conn.Write(b); err != nil { + return 0, err + } + return len(b), nil +} + +func (c *XorConn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + n, err := c.Conn.Read(b) + for p := b[:n]; ; { + if len(p) <= c.InSkip { + c.InSkip -= len(p) + break + } + p = p[c.InSkip:] + c.InSkip = 0 + need := 5 - len(c.InHeader) + if len(p) < need { + c.PeerCTR.XORKeyStream(p, p) + c.InHeader = append(c.InHeader, p...) + break + } + c.PeerCTR.XORKeyStream(p[:need], p[:need]) + c.InSkip, _ = DecodeHeader(append(c.InHeader, p[:need]...)) + c.InHeader = c.InHeader[:0] + p = p[need:] + } + return n, err +} + +// Upstream returns the underlying connection, allowing Vision to unwrap and access the TLS connection +func (c *XorConn) Upstream() any { + return c.Conn +} + +func (c *XorConn) IsEncryptionLayer() bool { + return true +} diff --git a/buildScript/lib/core/singbox-vless-encryption/lx_encryption.go b/buildScript/lib/core/singbox-vless-encryption/lx_encryption.go new file mode 100644 index 0000000000..693f6fd4ec --- /dev/null +++ b/buildScript/lib/core/singbox-vless-encryption/lx_encryption.go @@ -0,0 +1,207 @@ +package vless + +// lx: parsing for the VLESS `encryption` spec string (SPEC 032). Kept in its own +// file so the upstream-owned outbound.go carries only the two-line wiring, and a +// future upstream implementation of this feature conflicts there rather than in +// the parser. + +import ( + "context" + "encoding/base64" + "net" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +// wrapEncryption performs the post-quantum handshake over an already-dialed +// conn, returning it unchanged when the layer is not configured. It sits above +// the transport/TLS and below the vless client, which stays unaware of it. +// A failed handshake closes the conn: the caller only propagates the error. +// +// lx: 050 — Handshake takes a bare net.Conn (the wire format is fixed by SPEC +// 032 and upstream Xray, so it grows no context parameter) and internally writes +// fragmented padding with sleeps in between. On a half-alive node those writes +// block forever, which is how URL tests turned into goroutines that outlived the +// box. The dial deadline is therefore applied to the conn for the duration of +// the handshake and cleared afterwards, so the caller's context governs it. +// +// WRITE side only, deliberately. A read deadline would cost correctness: an XHTTP +// read deadline is one-shot (it closes the late-bound download body, and clearing +// it cannot reopen that), so a handshake that overran the dial deadline but still +// succeeded would hand back a conn whose download side is already dead. SetDeadline +// covers both directions, hence the narrower call. The read side is bounded by +// guardHandshake instead, which closes the conn only on the failure path. +func (h *vlessDialer) wrapEncryption(ctx context.Context, conn net.Conn) (net.Conn, error) { + if h.encryption == nil { + return conn, nil + } + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetWriteDeadline(deadline); err == nil { + defer conn.SetWriteDeadline(time.Time{}) + } + } + finish := guardHandshake(ctx, conn) + encryptedConn, err := h.encryption.Handshake(conn) + if !finish() { + // The guard won the race and owns the conn: it closed it, which is what + // unblocked the handshake. Whatever Handshake returned rides on a dead + // conn, so drop it without closing — closing here would race the guard. + return nil, E.Cause(ctx.Err(), "encryption handshake") + } + if err != nil { + common.Close(conn) + return nil, E.Cause(err, "encryption handshake") + } + return encryptedConn, nil +} + +// guardHandshake bounds the READ side of the handshake by the dial context and +// returns a finish func reporting whether the handshake, not the guard, won. +// +// lx: 050 — Handshake ends in a blocking io.ReadFull for the server's reply. On a +// node that accepts the connection and then says nothing that read never returns: +// a bare TCP conn has no read deadline of its own, and on XHTTP the response body +// is late-bound, so Read parks before there is even anything to time out. Nothing +// above can intervene, because the conn has not been handed up yet — the caller is +// still inside DialContext. That is how a URL test against a half-alive node turned +// into a goroutine holding its outbound forever, outliving the box it belonged to. +// +// Closing the conn is the only lever that reaches a parked read, so the guard owns +// the conn until the handshake returns. The claim is atomic in both directions: a +// handshake that completes at the instant the context dies still wins and keeps its +// conn, and a guard that fires first reports a dead conn rather than a result that +// merely looks healthy. +// +// This is NOT the dial-context watchdog SPEC 077 removed. That one lived inside the +// transport and kept listening after DialContext returned, which broke every pooled +// consumer (the DNS pool cancels the dial context the moment dial returns, by the +// net.Dialer contract). This guard is stopped before wrapEncryption returns, so it +// cannot observe a cancellation that arrives after the dial — the contract of SPEC +// 077 §2 holds unchanged. +func guardHandshake(ctx context.Context, conn net.Conn) func() bool { + done := ctx.Done() + if done == nil { + return func() bool { return true } + } + var claimed atomic.Bool + finished := make(chan struct{}) + go func() { + select { + case <-done: + if claimed.CompareAndSwap(false, true) { + common.Close(conn) + } + case <-finished: + } + }() + return func() bool { + won := claimed.CompareAndSwap(false, true) + close(finished) + return won + } +} + +// encryptionPrefix is the only handshake method that exists today. +const encryptionPrefix = "mlkem768x25519plus" + +// xorMode selects how the layer looks on the wire: AEAD headers shaped like +// TLSv1.3 (native), XOR-ed by public key (xorpub), or fully random. +const ( + xorModeNative uint32 = 0 + xorModeXorPub uint32 = 1 + xorModeRandom uint32 = 2 +) + +// keyLenX25519 and keyLenMLKEM768 are the two accepted public-key sizes; a +// segment decoding to anything else is a malformed key rather than padding. +const ( + keyLenX25519 = 32 + keyLenMLKEM768 = 1184 +) + +// paddingSegmentMaxLen bounds how long a segment may be and still be read as a +// padding block. Keys are base64 of 32 or 1184 bytes, so they are always longer; +// padding blocks look like "100-111-1111". +const paddingSegmentMaxLen = 20 + +type clientEncryptionConfig struct { + keys [][]byte + xorMode uint32 + seconds uint32 + padding string +} + +// parseClientEncryption reads the spec string: +// +// mlkem768x25519plus..<0rtt|1rtt>[.…].[.…] +// +// Errors name the offending segment so a bad config fails at check/start time +// with something actionable, rather than silently failing to connect later. +func parseClientEncryption(raw string) (clientEncryptionConfig, error) { + var cfg clientEncryptionConfig + raw = strings.TrimSpace(raw) + if raw == "" { + return cfg, E.New("empty encryption string") + } + parts := strings.Split(raw, ".") + if len(parts) < 4 { + return cfg, E.New("invalid encryption string: expected at least method.appearance.rtt.key, got ", len(parts), " segments") + } + if parts[0] != encryptionPrefix { + return cfg, E.New("unsupported encryption method: ", parts[0], " (only ", encryptionPrefix, " exists)") + } + switch parts[1] { + case "native": + cfg.xorMode = xorModeNative + case "xorpub": + cfg.xorMode = xorModeXorPub + case "random": + cfg.xorMode = xorModeRandom + default: + return cfg, E.New("unknown encryption appearance: ", parts[1], " (expected native|xorpub|random)") + } + switch parts[2] { + case "0rtt": + cfg.seconds = 1 + case "1rtt": + cfg.seconds = 0 + default: + return cfg, E.New("unknown encryption RTT mode: ", parts[2], " (expected 0rtt|1rtt)") + } + + // Remaining segments are padding blocks first, then keys. Once a key is seen + // the padding phase is over — that is how the reference implementation tells + // the two apart, since both are dot-separated and otherwise unlabelled. + paddingPhase := true + var paddingParts []string + for _, segment := range parts[3:] { + segment = strings.TrimSpace(segment) + if segment == "" { + return cfg, E.New("empty segment in encryption string") + } + if paddingPhase && len(segment) < paddingSegmentMaxLen { + paddingParts = append(paddingParts, segment) + continue + } + data, err := base64.RawURLEncoding.DecodeString(segment) + if err != nil { + return cfg, E.New("invalid encryption key (not base64url): ", segment) + } + if len(data) != keyLenX25519 && len(data) != keyLenMLKEM768 { + return cfg, E.New("invalid encryption key length: ", len(data), " (expected ", keyLenX25519, " or ", keyLenMLKEM768, ")") + } + cfg.keys = append(cfg.keys, data) + paddingPhase = false + } + if len(cfg.keys) == 0 { + return cfg, E.New("no encryption keys in encryption string") + } + if len(paddingParts) > 0 { + cfg.padding = strings.Join(paddingParts, ".") + } + return cfg, nil +} From ffd77fd2811c04d82fdcde922f07d74e8b0bcec7 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 10:56:04 +0000 Subject: [PATCH 50/62] feat: add per-tag url test in a single box instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为整组并发测速提供内核侧能力: - boxapi.DialContextWithTag / CreateProxyHttpClientWithTag:强制经指定出站拨号 - libcore.UrlTestTag(box, tag, link, timeout):在已启动实例内按 tag 测速 已用 1.14 源码 + patch + libneko 在本地完成 go build 验证。 --- libcore/box.go | 14 ++++++++++++++ libcore/boxapi/go_http.go | 16 ++++++++++++++++ libcore/boxapi/go_net.go | 13 +++++++++++++ 3 files changed, 43 insertions(+) diff --git a/libcore/box.go b/libcore/box.go index 3417028d33..cd7960b440 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -228,6 +228,20 @@ func UrlTest(i *BoxInstance, link string, timeout int32) (latency int32, err err return speedtest.UrlTest(boxapi.CreateProxyHttpClient(mainInstance.Box, connectionTracker), link, timeout, speedtest.UrlTestStandard_RTT) } +// UrlTestTag 在已启动的实例内,按 outbound tag 测速(不重启内核)。 +// 供整组并发测速使用:一次 init,逐 tag 拨号,调用方并发拿结果逐条回显。 +func UrlTestTag(i *BoxInstance, tag string, link string, timeout int32) (latency int32, err error) { + defer device.DeferPanicToError("box.UrlTestTag", func(err_ error) { err = err_ }) + if i == nil || i.Box == nil { + return 0, errors.New("box is not initialized") + } + var connectionTracker adapter.ConnectionTracker + if i.v2api != nil { + connectionTracker = i.v2api.StatsService() + } + return speedtest.UrlTest(boxapi.CreateProxyHttpClientWithTag(i.Box, connectionTracker, tag), link, timeout, speedtest.UrlTestStandard_RTT) +} + var protectCloser io.Closer func goServeProtect(start bool) { diff --git a/libcore/boxapi/go_http.go b/libcore/boxapi/go_http.go index 3cba5ebce8..424a48615d 100644 --- a/libcore/boxapi/go_http.go +++ b/libcore/boxapi/go_http.go @@ -28,3 +28,19 @@ func CreateProxyHttpClient(box *box.Box, tracker adapter.ConnectionTracker) *htt return client } + +// CreateProxyHttpClientWithTag 返回的 client 所有请求都强制经由指定出站 tag。 +func CreateProxyHttpClientWithTag(box *box.Box, tracker adapter.ConnectionTracker, tag string) *http.Client { + transport := &http.Transport{ + TLSHandshakeTimeout: time.Second * 3, + ResponseHeaderTimeout: time.Second * 3, + } + + if box != nil { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return DialContextWithTag(ctx, box, tracker, tag, network, addr) + } + } + + return &http.Client{Transport: transport} +} diff --git a/libcore/boxapi/go_net.go b/libcore/boxapi/go_net.go index 5a7637d1d7..6df3d23fae 100644 --- a/libcore/boxapi/go_net.go +++ b/libcore/boxapi/go_net.go @@ -21,3 +21,16 @@ func DialContext(ctx context.Context, box *box.Box, tracker adapter.ConnectionTr } return conn, nil } + +// DialContextWithTag 与 DialContext 相同,但强制经指定出站 tag 拨号, +// 用于单实例内的按节点并发测速(对标 Clash 的 /proxies/{name}/delay)。 +func DialContextWithTag(ctx context.Context, box *box.Box, tracker adapter.ConnectionTracker, tag, network, addr string) (net.Conn, error) { + conn, err := dialer.NewDetour(box.Outbound(), tag, true).DialContext(ctx, network, metadata.ParseSocksaddr(addr)) + if err != nil { + return nil, err + } + if ss, ok := tracker.(*SbStatsService); ok { + conn = ss.RoutedConnectionInternal("", tag, "", conn, false) + } + return conn, nil +} From 39de4141d410a7cbc3830e97075f09e922d9a064 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 10:56:19 +0000 Subject: [PATCH 51/62] feat: batch url test with a single kernel instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对标 Clash 的 /proxies/{name}/delay:整组节点只构建并启动一次内核, 按 outbound tag 并发拨号,每测完一个立刻回显(延迟+国旗同时出现)。 - ConfigBuilder 新增 testProfiles 参数,一次建出组内全部节点的 outbound - BatchTestInstance:一次 init/launch,插件节点一并拉起后再开始测速 - urlTest() 改为单实例流程;启动失败时整组标记失败而不是卡在测试中 - 单节点测速按钮仍走原 TestInstance --- .../sagernet/bg/proto/BatchTestInstance.kt | 70 ++++++++++++++ .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 24 +++-- .../sagernet/ui/ConfigurationFragment.kt | 95 +++++++++++++------ 3 files changed, 150 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt new file mode 100644 index 0000000000..11e1caf844 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -0,0 +1,70 @@ +package io.nekohasekai.sagernet.bg.proto + +import io.nekohasekai.sagernet.BuildConfig +import io.nekohasekai.sagernet.bg.GuardedProcessPool +import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.fmt.buildConfig +import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher +import io.nekohasekai.sagernet.ktx.tryResume +import io.nekohasekai.sagernet.ktx.tryResumeWithException +import kotlinx.coroutines.delay +import libcore.Libcore +import moe.matsuri.nb4a.net.LocalResolverImpl +import kotlin.coroutines.suspendCoroutine + +/** + * 整组共用一个内核实例的测速器,对标 Clash 的 `/proxies/{name}/delay`: + * 只 init/launch 一次,随后按 outbound tag 并发拨号,测完一个立刻回显。 + * + * 原实现([TestInstance])每个节点都要新建并启动一次内核,节点多时开销很大。 + */ +class BatchTestInstance( + private val profiles: List, + val link: String, + private val timeout: Int, +) : BoxInstance(profiles.first()) { + + /** profileId -> outbound tag,由批量配置的 buildChain 生成 */ + val tagMap: Map + get() = config.tagMap + + /** + * 构建配置、初始化并启动内核(插件节点会在此一并拉起)。 + * 只需调用一次,之后用 [testTag] 并发测速。 + */ + suspend fun prepare() = suspendCoroutine { c -> + processes = GuardedProcessPool { + Logs.w(it) + c.tryResumeWithException(it) + } + runOnDefaultDispatcher { + try { + init() + launch() + if (processes.processCount > 0) { + // wait for plugin start + delay(500) + } + c.tryResume(Unit) + } catch (e: Exception) { + c.tryResumeWithException(e) + } + } + } + + /** 在已启动的实例内测指定节点,返回 RTT(毫秒),失败抛异常 */ + fun testTag(tag: String): Int { + return Libcore.urlTestTag(box, tag, link, timeout) + } + + override fun buildConfig() { + config = buildConfig(profile, true, testProfiles = profiles) + } + + override suspend fun loadConfig() { + // don't call destroyAllJsi here + if (BuildConfig.DEBUG) Logs.d(config.config) + box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl) + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index 3ae3b92707..7623e10b66 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -156,7 +156,11 @@ private fun serverHostOf(bean: AbstractBean): String? { } fun buildConfig( - proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean = false + proxy: ProxyEntity, + forTest: Boolean = false, + forExport: Boolean = false, + // 整组测速:一次构建组内全部节点的 outbound,配合单实例按 tag 并发测速 + testProfiles: List? = null, ): ConfigBuildResult { if (proxy.type == TYPE_CONFIG) { @@ -632,17 +636,19 @@ fun buildConfig( } // build outbounds - if (buildSelector) { - val list = group.id.let { SagerDatabase.proxyDao.getByGroup(it) } + if (buildSelector || testProfiles != null) { + val list = testProfiles ?: group.id.let { SagerDatabase.proxyDao.getByGroup(it) } list.forEach { tagMap[it.id] = buildChain(it.id, it) } - outbounds.add(0, Outbound_SelectorOptions().apply { - type = "selector" - tag = TAG_PROXY - default_ = tagMap[proxy.id] - outbounds = tagMap.values.toList() - }) + if (buildSelector) { + outbounds.add(0, Outbound_SelectorOptions().apply { + type = "selector" + tag = TAG_PROXY + default_ = tagMap[proxy.id] + outbounds = tagMap.values.toList() + }) + } } else { val mainTag = buildChain(0, proxy) tagMap[proxy.id] = mainTag diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 69775be0f7..e285ba45b1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -52,6 +52,7 @@ import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.bg.BaseService import io.nekohasekai.sagernet.bg.proto.UrlTest +import io.nekohasekai.sagernet.bg.proto.BatchTestInstance import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.GroupManager import io.nekohasekai.sagernet.database.ProfileManager @@ -1320,45 +1321,79 @@ class ConfigurationFragment @JvmOverloads constructor( } test.proxyN = profilesList.size val profiles = ConcurrentLinkedQueue(profilesList) - repeat(DataStore.connectionTestConcurrent) { - testJobs.add(launch(Dispatchers.IO) { - val urlTest = UrlTest() // note: this is NOT in bg process - while (isActive) { - val profile = profiles.poll() ?: break - profile.status = 0 + // 单实例测速(对标 Clash 的 /proxies/{name}/delay): + // 整组只构建并启动一次内核,随后按 outbound tag 并发拨号 + var batch: BatchTestInstance? = null + try { + if (profilesList.isNotEmpty()) { + val instance = BatchTestInstance( + profilesList, + DataStore.connectionTestURL, + DataStore.connectionTestTimeout + ) + batch = instance + instance.prepare() + val tags = instance.tagMap + + repeat(DataStore.connectionTestConcurrent.coerceAtLeast(1)) { + testJobs.add(launch(Dispatchers.IO) { + while (isActive) { + val profile = profiles.poll() ?: break + profile.status = 0 + val tag = tags[profile.id] + if (tag == null) { + profile.status = 3 + profile.error = "missing outbound tag" + } else try { + val result = instance.testTag(tag) + profile.status = 1 + profile.ping = result + } catch (e: PluginManager.PluginNotFoundException) { + profile.status = 2 + profile.error = e.readableMessage + } catch (e: Exception) { + profile.status = 3 + profile.error = e.readableMessage + } - try { - val result = urlTest.doTest(profile) - profile.status = 1 - profile.ping = result - } catch (e: PluginManager.PluginNotFoundException) { - profile.status = 2 - profile.error = e.readableMessage - } catch (e: Exception) { - profile.status = 3 - profile.error = e.readableMessage - } + // 顺带解析节点服务器的归属国家,供卡片显示国旗 + CountryFlags.query(profile) + // 立即落库并刷新该行,让延迟和国旗同时出现 + // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) + try { + ProfileManager.updateProfile(profile) + } catch (e: Exception) { + Logs.w(e) + runOnMainDispatcher { + getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) + } + } - // 顺带解析节点服务器的归属国家,供卡片显示国旗 - CountryFlags.query(profile) - // 立即落库并刷新该行,让延迟和国旗同时出现 - // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) + test.update(profile) + } + }) + } + + testJobs.joinAll() + } + } catch (e: Exception) { + // 内核启动失败:整组标记失败,避免所有节点停在「测试中」 + Logs.w(e) + for (profile in profilesList) { + if (profile.status == 0) { + profile.status = 3 + profile.error = e.readableMessage try { ProfileManager.updateProfile(profile) - } catch (e: Exception) { - Logs.w(e) - runOnMainDispatcher { - getCurrentGroupFragment()?.adapter?.notifyProfileChanged(profile.id) - } + } catch (ignored: Exception) { } - test.update(profile) } - }) + } + } finally { + batch?.close() } - testJobs.joinAll() - runOnMainDispatcher { test.cancel() } From 63db35166901b7d1da1439277a812d4393a39979 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 11:07:00 +0000 Subject: [PATCH 52/62] fix: compile errors in batch url test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigBuildResult 字段名是 profileTagMap,不是 tagMap - group 是可空的 ProxyGroup,走安全调用并兜底空列表 --- .../io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt | 2 +- .../main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt index 11e1caf844..13522a4dec 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -27,7 +27,7 @@ class BatchTestInstance( /** profileId -> outbound tag,由批量配置的 buildChain 生成 */ val tagMap: Map - get() = config.tagMap + get() = config.profileTagMap /** * 构建配置、初始化并启动内核(插件节点会在此一并拉起)。 diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index 7623e10b66..6a53549720 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -637,7 +637,9 @@ fun buildConfig( // build outbounds if (buildSelector || testProfiles != null) { - val list = testProfiles ?: group.id.let { SagerDatabase.proxyDao.getByGroup(it) } + val list = testProfiles + ?: group?.id?.let { SagerDatabase.proxyDao.getByGroup(it) } + ?: emptyList() list.forEach { tagMap[it.id] = buildChain(it.id, it) } From 44e2286c8b7f801388c9ca72f74d18bd121eb039 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 11:39:36 +0000 Subject: [PATCH 53/62] refactor: remove the server-ip based country flag system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查服务器 IP 的归属地无法反映真实出口(中转/落地分离、CDN 入口都会错), 整套国旗逻辑(显示、缓存、清理联动、geoip 查询 API)全部移除,由出口实测方案取代。 --- .../sagernet/database/GroupManager.kt | 7 - .../sagernet/database/ProfileManager.kt | 8 - .../nekohasekai/sagernet/group/RawUpdater.kt | 5 - .../nekohasekai/sagernet/ktx/CountryFlags.kt | 157 ------------------ .../sagernet/ui/ConfigurationFragment.kt | 17 -- app/src/main/res/layout/layout_profile.xml | 10 -- libcore/geoip.go | 56 ------- 7 files changed, 260 deletions(-) delete mode 100644 app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt index f73fd49fef..b2688f498f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/GroupManager.kt @@ -2,7 +2,6 @@ package io.nekohasekai.sagernet.database import io.nekohasekai.sagernet.GroupType import io.nekohasekai.sagernet.bg.SubscriptionUpdater -import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.applyDefaultValues object GroupManager { @@ -55,8 +54,6 @@ object GroupManager { } suspend fun clearGroup(groupId: Long) { - // 清空分组时同步清理该组全部节点的国旗缓存 - CountryFlags.forgetProfiles(SagerDatabase.proxyDao.getByGroup(groupId)) DataStore.selectedProxy = 0L SagerDatabase.proxyDao.deleteAll(groupId) iterator { groupUpdated(groupId) } @@ -101,7 +98,6 @@ object GroupManager { } suspend fun deleteGroup(groupId: Long) { - CountryFlags.forgetProfiles(SagerDatabase.proxyDao.getByGroup(groupId)) SagerDatabase.groupDao.deleteById(groupId) SagerDatabase.proxyDao.deleteByGroup(groupId) iterator { groupRemoved(groupId) } @@ -109,9 +105,6 @@ object GroupManager { } suspend fun deleteGroup(group: List) { - for (proxyGroup in group) { - CountryFlags.forgetProfiles(SagerDatabase.proxyDao.getByGroup(proxyGroup.id)) - } SagerDatabase.groupDao.deleteGroup(group) SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray()) for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt index 383cb094d0..661db42492 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProfileManager.kt @@ -4,7 +4,6 @@ import android.database.sqlite.SQLiteCantOpenDatabaseException import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.aidl.TrafficData import io.nekohasekai.sagernet.fmt.AbstractBean -import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.app import io.nekohasekai.sagernet.ktx.applyDefaultValues @@ -109,10 +108,6 @@ object ProfileManager { } suspend fun deleteProfile2(groupId: Long, profileId: Long) { - // 删除节点时同步清理国旗缓存(订阅更新 / 清理不可用 / 多选 / 设置页都走这里) - SagerDatabase.proxyDao.getById(profileId)?.let { - CountryFlags.forgetProfiles(listOf(it)) - } if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L @@ -120,9 +115,6 @@ object ProfileManager { } suspend fun deleteProfile(groupId: Long, profileId: Long) { - SagerDatabase.proxyDao.getById(profileId)?.let { - CountryFlags.forgetProfiles(listOf(it)) - } if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return if (DataStore.selectedProxy == profileId) { DataStore.selectedProxy = 0L diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index a9da4581af..1e1644f839 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.SubscriptionFilterMode import io.nekohasekai.sagernet.database.* -import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.http.HttpBean import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean @@ -233,10 +232,6 @@ object RawUpdater : GroupUpdater() { Logs.d("Updated profiles: $it") } - // 订阅更新删除的节点,同步清理国旗缓存(删除前实体还在,能取到服务器地址) - if (toDelete.isNotEmpty()) { - CountryFlags.forgetProfiles(toDelete) - } SagerDatabase.proxyDao.deleteProxy(toDelete).also { Logs.d("Deleted profiles: $it") } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt deleted file mode 100644 index 3d1f868822..0000000000 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/CountryFlags.kt +++ /dev/null @@ -1,157 +0,0 @@ -package io.nekohasekai.sagernet.ktx - -import android.content.Context -import io.nekohasekai.sagernet.database.ProxyEntity -import libcore.Libcore -import java.net.InetAddress -import java.util.concurrent.ConcurrentHashMap - -/** - * IP 归属地国旗缓存。 - * - * 查询走本地 geoip.db(无需联网):服务器地址 -> IP -> ISO 国家码。 - * 结果按 服务器地址 持久化到 SharedPreferences,重启后仍然有效; - * profileId 映射只存在内存里,卡片首次绑定时按地址回填。 - */ -object CountryFlags { - - private const val PREFS_NAME = "country_flags" - private const val RETRY_COOL_DOWN_MS = 60_000L - - private val byProfile = ConcurrentHashMap() // profileId -> ISO 代码 - private val byHost = ConcurrentHashMap() // serverAddress -> ISO 代码 - private val failedAt = ConcurrentHashMap() // 失败时间戳,冷却重试 - - private val prefs by lazy { - app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - init { - // 把上次运行持久化的结果读回内存 - try { - prefs.all.forEach { (host, code) -> - if (host is String && code is String && code.length == 2) { - byHost[host] = code - } - } - } catch (ignored: Exception) { - } - } - - fun get(profileId: Long): String? = byProfile[profileId] - - /** 优先按 profile 取,其次按服务器地址取(持久化数据在重启后走这条路径) */ - fun resolve(profile: ProxyEntity): String? { - byProfile[profile.id]?.let { return it } - val host = hostOf(profile) - if (host.isBlank()) return null - return byHost[host]?.also { byProfile[profile.id] = it } - } - - fun forget(profileId: Long) { - byProfile.remove(profileId) - } - - private fun hostOf(profile: ProxyEntity): String = try { - profile.requireBean().serverAddress.orEmpty() - } catch (e: Exception) { - "" - } - - /** - * 查询节点服务器地址的归属国家,阻塞调用,必须在后台线程执行。 - * 返回 ISO 3166-1 两位代码,查不到返回空串。 - */ - fun query(profile: ProxyEntity): String { - val host = hostOf(profile) - if (host.isBlank()) return "" - - byHost[host]?.let { cached -> - byProfile[profile.id] = cached - return cached - } - - val failedTime = failedAt[host] - if (failedTime != null && - System.currentTimeMillis() - failedTime < RETRY_COOL_DOWN_MS - ) return "" - - // 域名先解析成 IP(IP 字面量会直接返回,不产生 DNS 请求) - val ip = try { - InetAddress.getByName(host).hostAddress - } catch (e: Exception) { - null - } - if (ip.isNullOrBlank()) { - failedAt[host] = System.currentTimeMillis() - return "" - } - - val code = try { - Libcore.queryGeoIPCountry(ip) ?: "" - } catch (e: Exception) { - "" - } - - if (code.length == 2) { - remember(host, profile.id, code) - return code - } - failedAt[host] = System.currentTimeMillis() - return "" - } - - private const val MAX_PERSISTED = 2000 - - private fun remember(host: String, profileId: Long, code: String) { - byHost[host] = code - byProfile[profileId] = code - failedAt.remove(host) - try { - // 持久化条目设上限:超限整体清空,后续查询自动重建(条目很小,重建成本低) - if (prefs.all.size >= MAX_PERSISTED) { - prefs.edit().clear().apply() - byHost.clear() - } - prefs.edit().putString(host, code).apply() - } catch (ignored: Exception) { - } - } - - /** - * 清除指定节点的图标缓存(内存 + 持久化)。 - * 「清理测试结果」时调用,让图标与延迟一起消失。 - */ - fun forgetProfiles(profiles: List) { - if (profiles.isEmpty()) return - val editor = prefs.edit() - var changed = false - for (profile in profiles) { - byProfile.remove(profile.id) - val host = hostOf(profile) - if (host.isBlank()) continue - byHost.remove(host) - failedAt.remove(host) - editor.remove(host) - changed = true - } - if (changed) { - try { - editor.apply() - } catch (ignored: Exception) { - } - } - } - - /** ISO 国家代码转国旗 emoji,如 "US" -> 🇺🇸 */ - fun flagOf(code: String?): String { - if (code == null || code.length != 2) return "" - val upper = code.uppercase() - val first = upper[0] - val second = upper[1] - if (first !in 'A'..'Z' || second !in 'A'..'Z') return "" - val base = 0x1F1E6 // Regional Indicator Symbol Letter A - return String(Character.toChars(base + (first - 'A'))) + - String(Character.toChars(base + (second - 'A'))) - } -} diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index e285ba45b1..5faf98a54c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -66,7 +66,6 @@ import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.RawUpdater -import io.nekohasekai.sagernet.ktx.CountryFlags import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager import io.nekohasekai.sagernet.ktx.FixedGridLayoutManager import io.nekohasekai.sagernet.ktx.Logs @@ -760,7 +759,6 @@ class ConfigurationFragment @JvmOverloads constructor( ProfileManager.updateProfile(toClear) } // 国家图标与本轮测试结果一起清理 - CountryFlags.forgetProfiles(profiles) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } @@ -1313,8 +1311,6 @@ class ConfigurationFragment @JvmOverloads constructor( Logs.w(e) } } - // 与「清理测试结果」保持一致:图标也一起清掉,随后由本轮测试重新产生 - CountryFlags.forgetProfiles(profilesList) onMainDispatcher { getCurrentGroupFragment()?.adapter?.clearTestResults() } @@ -1356,8 +1352,6 @@ class ConfigurationFragment @JvmOverloads constructor( profile.error = e.readableMessage } - // 顺带解析节点服务器的归属国家,供卡片显示国旗 - CountryFlags.query(profile) // 立即落库并刷新该行,让延迟和国旗同时出现 // (只 notifyItemChanged 不会更新延迟:adapter 里缓存的是旧对象) try { @@ -2440,7 +2434,6 @@ class ConfigurationFragment @JvmOverloads constructor( val profileName: TextView = view.findViewById(R.id.profile_name) val profileType: TextView = view.findViewById(R.id.profile_type) - val profileCountry: TextView = view.findViewById(R.id.profile_country) val profileAddress: TextView = view.findViewById(R.id.profile_address) val profileStatus: TextView = view.findViewById(R.id.profile_status) @@ -2543,8 +2536,6 @@ class ConfigurationFragment @JvmOverloads constructor( proxyEntity.status = 3 proxyEntity.error = e.readableMessage } - // 顺带解析节点服务器的归属国家,供卡片显示国旗 - CountryFlags.query(proxyEntity) try { ProfileManager.updateProfile(proxyEntity) } catch (e: Exception) { @@ -2743,14 +2734,6 @@ class ConfigurationFragment @JvmOverloads constructor( profileType.text = proxyEntity.displayType() profileType.setTextColor(requireContext().getProtocolColor(proxyEntity.type)) - val countryFlag = CountryFlags.flagOf(CountryFlags.resolve(proxyEntity)) - if (countryFlag.isEmpty()) { - profileCountry.isGone = true - } else { - profileCountry.text = countryFlag - profileCountry.isVisible = true - } - val rx = proxyEntity.rx val tx = proxyEntity.tx diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml index 29d0147c58..b17c4562c2 100644 --- a/app/src/main/res/layout/layout_profile.xml +++ b/app/src/main/res/layout/layout_profile.xml @@ -187,16 +187,6 @@ android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="?android:textColorSecondary" /> - - Date: Fri, 6 Nov 2026 11:40:00 +0000 Subject: [PATCH 54/62] feat: show real egress country and ip in the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连接成功后经当前连接请求 Cloudflare trace(cdn-cgi/trace), 解析 ip= 与 loc=,在状态栏右侧显示国旗+国家名+出口 IP。 - libcore.HttpGet:经 mainInstance,按当前连接的出口发起 GET - EgressInfo:解析与缓存,连接刚建立时自动重试最多 3 次 - 断开/连接中清空显示;换节点后重新连接会刷新 --- .../io/nekohasekai/sagernet/ktx/EgressInfo.kt | 89 +++++++++++++++++++ .../nekohasekai/sagernet/widget/StatsBar.kt | 46 ++++++++++ app/src/main/res/layout/layout_main.xml | 41 ++++++++- libcore/box.go | 22 +++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt new file mode 100644 index 0000000000..9b7961adfe --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt @@ -0,0 +1,89 @@ +package io.nekohasekai.sagernet.ktx + +import libcore.Libcore +import java.util.Locale + +/** + * 通过当前连接请求 Cloudflare trace,获取实际出口 IP 与归属国家。 + * + * 与节点服务器地址的 geoip 查询不同,这里反映的是真实落地: + * 中转/落地分离、CDN 入口等场景都能得到正确结果。 + */ +object EgressInfo { + + data class Result(val ip: String, val countryCode: String) { + + /** 本地化国家名,如 "美国" */ + val countryName: String + get() { + if (countryCode.length != 2) return "" + return try { + Locale.Builder().setRegion(countryCode).build().displayCountry + } catch (e: Exception) { + "" + } + } + + /** ISO 国家码转国旗 emoji,如 "US" -> 🇺🇸 */ + val flag: String + get() { + if (countryCode.length != 2) return "" + val upper = countryCode.uppercase() + val first = upper[0] + val second = upper[1] + if (first !in 'A'..'Z' || second !in 'A'..'Z') return "" + val base = 0x1F1E6 // Regional Indicator Symbol Letter A + return String(Character.toChars(base + (first - 'A'))) + + String(Character.toChars(base + (second - 'A'))) + } + + /** 一行展示文本,如 "🇺🇸 美国" */ + val display: String + get() { + val name = countryName + return when { + flag.isNotEmpty() && name.isNotEmpty() -> "$flag $name" + name.isNotEmpty() -> name + flag.isNotEmpty() -> flag + else -> countryCode + } + } + } + + private const val TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace" + + @Volatile + var current: Result? = null + private set + + fun clear() { + current = null + } + + /** + * 请求 trace 并解析出口 IP / 国家码,阻塞调用,必须在后台线程执行。 + * 失败返回 null(连接刚建立时可能尚未就绪,调用方自行重试)。 + */ + fun fetch(): Result? { + val body = try { + Libcore.httpGet(TRACE_URL, 5000) + } catch (e: Exception) { + return null + } + if (body.isNullOrBlank()) return null + + var ip = "" + var loc = "" + for (line in body.lineSequence()) { + when { + line.startsWith("ip=") -> ip = line.removePrefix("ip=").trim() + line.startsWith("loc=") -> loc = line.removePrefix("loc=").trim() + } + } + if (ip.isEmpty()) return null + + val result = Result(ip, loc.uppercase()) + current = result + return result + } +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index 728a4cca34..2c73b1ce41 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class StatsBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -41,6 +42,9 @@ class StatsBar @JvmOverloads constructor( private lateinit var statusText: TextView private lateinit var txText: TextView private lateinit var rxText: TextView + private lateinit var egressCountry: TextView + private lateinit var egressIp: TextView + private var egressJob: Job? = null @Suppress("unused") private lateinit var behavior: YourBehavior private var currentState = BaseService.State.Idle @@ -119,9 +123,49 @@ class StatsBar @JvmOverloads constructor( statusText = findViewById(R.id.status) txText = findViewById(R.id.tx) rxText = findViewById(R.id.rx) + egressCountry = findViewById(R.id.egress_country) + egressIp = findViewById(R.id.egress_ip) super.setOnClickListener(l) } + /** + * 连接成功后请求出口 IP / 归属国家并显示在状态栏右侧。 + * 刚连上时可能还没就绪,失败会重试几次。 + */ + private fun refreshEgress() { + val activity = context as? MainActivity ?: return + egressJob?.cancel() + egressJob = activity.lifecycleScope.launch(Dispatchers.IO) { + var fetched: EgressInfo.Result? = EgressInfo.current + for (attempt in 0..2) { + if (fetched != null) break + fetched = EgressInfo.fetch() + if (fetched != null) break + delay(1500L) + } + val info = fetched ?: return@launch + withContext(Dispatchers.Main) { + if (!::egressCountry.isInitialized) return@withContext + egressCountry.text = info.display + egressIp.text = info.ip + egressCountry.isVisible = true + egressIp.isVisible = true + } + } + } + + private fun clearEgress() { + egressJob?.cancel() + egressJob = null + EgressInfo.clear() + if (::egressCountry.isInitialized) { + egressCountry.text = null + egressIp.text = null + egressCountry.isGone = true + egressIp.isGone = true + } + } + private fun setStatus(text: CharSequence) { statusText.text = text TooltipCompat.setTooltipText(this, text) @@ -279,7 +323,9 @@ class StatsBar @JvmOverloads constructor( updateHideOnScroll() if (state == BaseService.State.Connected) { setStatus(app.getText(R.string.vpn_connected)) + refreshEgress() } else { + clearEgress() updateSpeed(0, 0) setStatus( context.getText( diff --git a/app/src/main/res/layout/layout_main.xml b/app/src/main/res/layout/layout_main.xml index 85092521ca..88937fb6b7 100644 --- a/app/src/main/res/layout/layout_main.xml +++ b/app/src/main/res/layout/layout_main.xml @@ -58,9 +58,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?android:attr/selectableItemBackground" - android:orientation="vertical" + android:gravity="center_vertical" + android:orientation="horizontal" android:padding="16dp"> + + + + + + + + + + + + diff --git a/libcore/box.go b/libcore/box.go index cd7960b440..857881fa97 100644 --- a/libcore/box.go +++ b/libcore/box.go @@ -11,6 +11,7 @@ import ( "runtime/debug" "strings" "sync" + "time" "github.com/matsuridayo/libneko/protect_server" "github.com/matsuridayo/libneko/speedtest" @@ -242,6 +243,27 @@ func UrlTestTag(i *BoxInstance, tag string, link string, timeout int32) (latency return speedtest.UrlTest(boxapi.CreateProxyHttpClientWithTag(i.Box, connectionTracker, tag), link, timeout, speedtest.UrlTestStandard_RTT) } +// HttpGet 经当前连接(mainInstance)发起 GET,返回响应体(最多 8KB)。 +// 用于获取实际出口 IP 与归属国家(如 Cloudflare trace),反映真实落地而非节点服务器地址。 +func HttpGet(url string, timeout int32) (body string, err error) { + defer device.DeferPanicToError("box.HttpGet", func(err_ error) { err = err_ }) + if mainInstance == nil || mainInstance.Box == nil { + return "", errors.New("box is not initialized") + } + client := boxapi.CreateProxyHttpClient(mainInstance.Box, nil) + client.Timeout = time.Duration(timeout) * time.Millisecond + resp, err := client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + buffer, err := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if err != nil { + return "", err + } + return string(buffer), nil +} + var protectCloser io.Closer func goServeProtect(start bool) { From 8f2a1ad181361aedeb8de9865d35d55967b057b4 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 11:43:23 +0000 Subject: [PATCH 55/62] feat: refresh egress info when switching nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分组 selector 切换走 cbSelectorUpdate,服务不重启,延迟 1s 后主动重新探测 - 普通分组换节点会重连:清掉旧出口信息,重连成功后再探测 - StatsBar 新增 refreshEgressInfo(延迟),清缓存 + 最多重试 3 次 --- .../sagernet/ui/ConfigurationFragment.kt | 3 ++ .../nekohasekai/sagernet/ui/MainActivity.kt | 2 ++ .../nekohasekai/sagernet/widget/StatsBar.kt | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 5faf98a54c..665b6aee54 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -66,6 +66,7 @@ import io.nekohasekai.sagernet.fmt.AbstractBean import io.nekohasekai.sagernet.fmt.toUniversalLink import io.nekohasekai.sagernet.group.GroupUpdater import io.nekohasekai.sagernet.group.RawUpdater +import io.nekohasekai.sagernet.ktx.EgressInfo import io.nekohasekai.sagernet.ktx.FixedLinearLayoutManager import io.nekohasekai.sagernet.ktx.FixedGridLayoutManager import io.nekohasekai.sagernet.ktx.Logs @@ -2570,6 +2571,8 @@ class ConfigurationFragment @JvmOverloads constructor( } if (update) { + // 换节点(非 selector 分组会重连):清掉旧出口信息,重连成功后重新探测 + EgressInfo.clear() ProfileManager.postUpdate(lastSelected, noTraffic = true) if (DataStore.serviceState.canStop && reloadAccess.tryLock()) { SagerNet.reloadService() diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt index d747f74fb2..d772435779 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt @@ -513,6 +513,8 @@ class MainActivity : ThemedActivity(), DataStore.selectedProxy = id DataStore.currentProfile = id refreshConfigurationProfileState() + // 分组内切换节点:服务不会重启,主动刷新出口 IP / 国家 + binding.stats.refreshEgressInfo(1000L) runOnDefaultDispatcher { ProfileManager.postUpdate(old, true) ProfileManager.postUpdate(id, true) diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index 2c73b1ce41..ff31e82b3f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -154,6 +154,38 @@ class StatsBar @JvmOverloads constructor( } } + /** + * 切换节点后刷新出口信息。 + * selector 即时切换不会改变服务状态,重连分支也可能命中缓存, + * 因此这里主动清缓存并延迟一小段时间等新出口就绪。 + */ + fun refreshEgressInfo(delayMs: Long = 0L) { + if (currentState != BaseService.State.Connected) { + EgressInfo.clear() + return + } + val activity = context as? MainActivity ?: return + egressJob?.cancel() + EgressInfo.clear() + egressJob = activity.lifecycleScope.launch(Dispatchers.IO) { + if (delayMs > 0) delay(delayMs) + var fetched: EgressInfo.Result? = null + for (attempt in 0..2) { + fetched = EgressInfo.fetch() + if (fetched != null) break + delay(1500L) + } + val info = fetched ?: return@launch + withContext(Dispatchers.Main) { + if (!::egressCountry.isInitialized) return@withContext + egressCountry.text = info.display + egressIp.text = info.ip + egressCountry.isVisible = true + egressIp.isVisible = true + } + } + } + private fun clearEgress() { egressJob?.cancel() egressJob = null From 472c4c1517ae86be28c67e3ad5e511c93262ebd3 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 11:50:33 +0000 Subject: [PATCH 56/62] fix: import androidx.core.view.isVisible/isGone in StatsBar --- app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index ff31e82b3f..a6872974d2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -8,6 +8,8 @@ import android.view.View import android.widget.TextView import androidx.appcompat.widget.TooltipCompat import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.lifecycle.whenStarted import com.google.android.material.bottomappbar.BottomAppBar From 71c0d38c67e0e82b6d5cba014082fce2ff77b880 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 11:59:42 +0000 Subject: [PATCH 57/62] fix: egress detection via mixed inbound with libcore fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前只走 libcore 默认出站,失败时状态栏一片空白无法判断。 现在优先用本机 mixed inbound(OkHttp,行为可控,支持代理认证), 失败再退回 libcore.httpGet;连接成功后先显示「检测中」,失败显示 --。 --- .../io/nekohasekai/sagernet/ktx/EgressInfo.kt | 68 +++++++++++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt index 9b7961adfe..39668b1440 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/EgressInfo.kt @@ -1,13 +1,23 @@ package io.nekohasekai.sagernet.ktx +import io.nekohasekai.sagernet.database.DataStore import libcore.Libcore +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.InetSocketAddress +import java.net.Proxy import java.util.Locale +import java.util.concurrent.TimeUnit /** * 通过当前连接请求 Cloudflare trace,获取实际出口 IP 与归属国家。 * * 与节点服务器地址的 geoip 查询不同,这里反映的是真实落地: * 中转/落地分离、CDN 入口等场景都能得到正确结果。 + * + * 有两条通道:优先走本机 mixed inbound(纯 Kotlin,行为可控), + * 失败时退回 libcore 的默认出站请求。 */ object EgressInfo { @@ -65,11 +75,7 @@ object EgressInfo { * 失败返回 null(连接刚建立时可能尚未就绪,调用方自行重试)。 */ fun fetch(): Result? { - val body = try { - Libcore.httpGet(TRACE_URL, 5000) - } catch (e: Exception) { - return null - } + val body = fetchBodyViaMixedInbound() ?: fetchBodyViaLibcore() if (body.isNullOrBlank()) return null var ip = "" @@ -80,10 +86,60 @@ object EgressInfo { line.startsWith("loc=") -> loc = line.removePrefix("loc=").trim() } } - if (ip.isEmpty()) return null + if (ip.isEmpty()) { + Logs.d("egress trace: no ip in response") + return null + } val result = Result(ip, loc.uppercase()) current = result return result } + + /** 走本机 mixed inbound(HTTP 代理) */ + private fun fetchBodyViaMixedInbound(): String? { + return try { + val port = DataStore.mixedPort + if (port <= 0) return null + val client = OkHttpClient.Builder() + .proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", port))) + .proxyAuthenticator { _, response -> + if (DataStore.mixedInboundHasAuth) { + response.request.newBuilder() + .header( + "Proxy-Authorization", + Credentials.basic(DataStore.mixedUsername, DataStore.mixedSecret) + ) + .build() + } else { + response.request + } + } + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + client.newCall(Request.Builder().url(TRACE_URL).build()).execute().use { response -> + if (!response.isSuccessful) { + Logs.d("egress trace via mixed: HTTP ${response.code}") + null + } else { + response.body?.string() + } + } + } catch (e: Exception) { + Logs.d("egress trace via mixed failed: ${e.message}") + null + } + } + + /** 退回经内核默认出站请求 */ + private fun fetchBodyViaLibcore(): String? { + return try { + Libcore.httpGet(TRACE_URL, 6000) + } catch (e: Exception) { + Logs.d("egress trace via libcore failed: ${e.message}") + null + } + } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a276e887ac..7faad7fee1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -297,6 +297,7 @@ 始终显示地址 始终在配置卡片上显示服务器地址 清空流量统计数据 + 检测中… 连接测试 清理测试结果 域名不存在 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f24a53af06..513056dc5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -352,6 +352,7 @@ Always display the server address on the configuration card Clear traffic statistics + Detecting… Connection test Clear test results TCPing From ed91880ea79cda3b47eb7f34ecb618a7d22d4e0e Mon Sep 17 00:00:00 2001 From: yekaipow Date: Fri, 6 Nov 2026 12:00:43 +0000 Subject: [PATCH 58/62] fix: show detecting placeholder while probing egress --- .../nekohasekai/sagernet/widget/StatsBar.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt index a6872974d2..3a7f3c1fd3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt @@ -137,6 +137,13 @@ class StatsBar @JvmOverloads constructor( private fun refreshEgress() { val activity = context as? MainActivity ?: return egressJob?.cancel() + if (::egressCountry.isInitialized) { + // 先给个占位,探测失败时也不会一片空白 + egressCountry.text = context.getString(R.string.egress_detecting) + egressCountry.isVisible = true + egressIp.text = null + egressIp.isGone = true + } egressJob = activity.lifecycleScope.launch(Dispatchers.IO) { var fetched: EgressInfo.Result? = EgressInfo.current for (attempt in 0..2) { @@ -145,13 +152,19 @@ class StatsBar @JvmOverloads constructor( if (fetched != null) break delay(1500L) } - val info = fetched ?: return@launch + val info = fetched withContext(Dispatchers.Main) { if (!::egressCountry.isInitialized) return@withContext - egressCountry.text = info.display - egressIp.text = info.ip - egressCountry.isVisible = true - egressIp.isVisible = true + if (info == null) { + egressCountry.text = "--" + egressIp.text = null + egressIp.isGone = true + } else { + egressCountry.text = info.display + egressIp.text = info.ip + egressCountry.isVisible = true + egressIp.isVisible = true + } } } } From c75192a06fb256c88235fadd8ec1ad4a60eefcef Mon Sep 17 00:00:00 2001 From: yekaipow Date: Sun, 8 Nov 2026 00:24:24 +0000 Subject: [PATCH 59/62] fix: isolate invalid profiles in batch url test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整组共用一个内核实例,组内任何一个节点配置非法(如 xhttp 传输内核不支持) 都会让 decode 失败,导致整组节点全部显示不可用。 现在: - 静态剔除使用了内核不支持传输(xhttp)的节点(含链内节点) - decode 失败时按 outbounds[N] 索引 + tag 反查定位节点,剔除后重建配置重试 - 被剔除的节点在 UI 上直接标为不可用并显示具体原因 - 单个节点测速失败后隔 300ms 重试一次,避免瞬时抖动误判 --- .../sagernet/bg/proto/BatchTestInstance.kt | 105 +++++++++++++++++- .../sagernet/ui/ConfigurationFragment.kt | 32 +++++- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt index 13522a4dec..c5be9efa84 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -3,14 +3,21 @@ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.ProxyEntity +import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.buildConfig +import io.nekohasekai.sagernet.fmt.internal.ChainBean +import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.ktx.Logs +import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ktx.tryResume import io.nekohasekai.sagernet.ktx.tryResumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl +import org.json.JSONObject import kotlin.coroutines.suspendCoroutine /** @@ -18,13 +25,24 @@ import kotlin.coroutines.suspendCoroutine * 只 init/launch 一次,随后按 outbound tag 并发拨号,测完一个立刻回显。 * * 原实现([TestInstance])每个节点都要新建并启动一次内核,节点多时开销很大。 + * + * 注意:整组共用一个配置,任何一个节点配置非法都会让整个内核起不来 + * (例如 xhttp 传输当前内核不支持,会报 "unknown transport type")。 + * 因此这里对单节点做了隔离:静态检查出内核明确不支持的节点先剔除, + * 动态 decode 失败时再按出错的 outbound 定位节点并剔除重试, + * 被剔除的节点记在 [excluded] 里由调用方标记失败,避免整组显示不可用。 */ class BatchTestInstance( - private val profiles: List, + profiles: List, val link: String, private val timeout: Int, ) : BoxInstance(profiles.first()) { + /** 被跳过的节点:profileId -> 原因 */ + val excluded = LinkedHashMap() + + private var activeProfiles: List = profiles + /** profileId -> outbound tag,由批量配置的 buildChain 生成 */ val tagMap: Map get() = config.profileTagMap @@ -33,7 +51,40 @@ class BatchTestInstance( * 构建配置、初始化并启动内核(插件节点会在此一并拉起)。 * 只需调用一次,之后用 [testTag] 并发测速。 */ - suspend fun prepare() = suspendCoroutine { c -> + suspend fun prepare() { + // 1) 静态剔除内核明确不支持的传输 + runOnDefaultDispatcher { + val usable = ArrayList() + for (profile in activeProfiles) { + val reason = unsupportedReason(profile) + if (reason == null) usable.add(profile) else excluded[profile.id] = reason + } + if (usable.isNotEmpty()) activeProfiles = usable + } + if (activeProfiles.isEmpty()) { + throw Exception("没有可测试的节点") + } + + // 2) 动态兜底:decode 失败时定位出错的节点,剔除后重建配置重试 + while (true) { + try { + startOnce() + return + } catch (e: Exception) { + Logs.w("batch test prepare failed: ${e.readableMessage}", e) + val badId = resolveFailedProfile(e) + if (badId == null || activeProfiles.size <= 1) { + throw e + } + Logs.w("batch test: drop profile $badId (${e.readableMessage})") + excluded[badId] = e.readableMessage + activeProfiles = activeProfiles.filterNot { it.id == badId } + releaseKernel() + } + } + } + + private suspend fun startOnce() = suspendCoroutine { c -> processes = GuardedProcessPool { Logs.w(it) c.tryResumeWithException(it) @@ -59,7 +110,8 @@ class BatchTestInstance( } override fun buildConfig() { - config = buildConfig(profile, true, testProfiles = profiles) + // 剔除节点后,主 profile 可能已不在列表里,取当前可用的第一个 + config = buildConfig(activeProfiles.first(), true, testProfiles = activeProfiles) } override suspend fun loadConfig() { @@ -67,4 +119,51 @@ class BatchTestInstance( if (BuildConfig.DEBUG) Logs.d(config.config) box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl) } + + /** 静态检查:节点(含链内节点)是否用了当前内核不支持的传输 */ + private fun unsupportedReason(profile: ProxyEntity): String? { + val bean = runCatching { profile.requireBean() }.getOrNull() ?: return null + if (bean is ChainBean) { + val beans = runCatching { SagerDatabase.proxyDao.getEntities(bean.proxies) } + .getOrNull() ?: return null + for (sub in beans) { + unsupportedReason(sub)?.let { return it } + } + return null + } + if (bean is StandardV2RayBean && bean.type == "xhttp") { + return "当前内核不支持 xhttp 传输" + } + return null + } + + /** + * 从 decode 错误里定位是哪个节点的 outbound 非法。 + * sing-box 的报错形如 "decode config: outbounds[20].transport: unknown transport type: xhttp", + * 取该 outbound 的 tag,再反查 profileId。 + */ + private fun resolveFailedProfile(e: Throwable): Long? { + val message = generateSequence(e) { it.cause } + .mapNotNull { it.message } + .joinToString(" | ") + val index = Regex("""outbounds\[(\d+)]""").find(message) + ?.groupValues?.get(1)?.toIntOrNull() ?: return null + val tag = runCatching { + JSONObject(config.config).getJSONArray("outbounds").getJSONObject(index).optString("tag") + }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return null + // 优先精确匹配最终 outbound tag + config.profileTagMap.entries.firstOrNull { it.value == tag }?.let { return it.key } + // 链中间节点的 tag 形如 c-- + return activeProfiles.firstOrNull { tag.endsWith("-${it.id}") }?.id + } + + /** 丢弃上一轮已创建但不可用的内核/插件进程 */ + private fun releaseKernel() { + runCatching { + if (::box.isInitialized) box.close() + } + if (::processes.isInitialized) { + runCatching { processes.close(GlobalScope + Dispatchers.IO) } + } + } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 665b6aee54..6c72178ced 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -1330,6 +1330,18 @@ class ConfigurationFragment @JvmOverloads constructor( ) batch = instance instance.prepare() + // 内核不支持/配置非法的节点已被剔除,直接判失败,不能让它们停在「测试中」 + for ((id, reason) in instance.excluded) { + val profile = profilesList.firstOrNull { it.id == id } ?: continue + profile.status = 3 + profile.error = reason + try { + ProfileManager.updateProfile(profile) + } catch (e: Exception) { + Logs.w(e) + } + test.update(profile) + } val tags = instance.tagMap repeat(DataStore.connectionTestConcurrent.coerceAtLeast(1)) { @@ -1342,7 +1354,23 @@ class ConfigurationFragment @JvmOverloads constructor( profile.status = 3 profile.error = "missing outbound tag" } else try { - val result = instance.testTag(tag) + // 并发拨号偶尔会被瞬时失败(DNS/握手抖动)误判, + // 失败后隔一小会儿重试一次再判不可用 + var result = 0 + var lastError: Exception? = null + for (attempt in 0 until 2) { + try { + result = instance.testTag(tag) + lastError = null + break + } catch (e: PluginManager.PluginNotFoundException) { + throw e + } catch (e: Exception) { + lastError = e + if (attempt == 0) delay(300L) + } + } + if (lastError != null) throw lastError!! profile.status = 1 profile.ping = result } catch (e: PluginManager.PluginNotFoundException) { @@ -1377,7 +1405,7 @@ class ConfigurationFragment @JvmOverloads constructor( for (profile in profilesList) { if (profile.status == 0) { profile.status = 3 - profile.error = e.readableMessage + profile.error = batch?.excluded?.get(profile.id) ?: e.readableMessage try { ProfileManager.updateProfile(profile) } catch (ignored: Exception) { From 0c2f32bff38f27bb4aff64b66e94f706efd77eb4 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Sun, 8 Nov 2026 00:35:43 +0000 Subject: [PATCH 60/62] fix: compile errors in batch test instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 父类的 lateinit 属性无法在子类里用 ::x.isInitialized 检查, 改用 runCatching 兜住 UninitializedPropertyAccessException; processes.close 的 scope 参数改用 CoroutineScope 构造,避免依赖 plus 扩展。 --- .../sagernet/bg/proto/BatchTestInstance.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt index c5be9efa84..2c5ead48d4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -12,8 +12,8 @@ import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher import io.nekohasekai.sagernet.ktx.tryResume import io.nekohasekai.sagernet.ktx.tryResumeWithException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import libcore.Libcore import moe.matsuri.nb4a.net.LocalResolverImpl @@ -159,11 +159,9 @@ class BatchTestInstance( /** 丢弃上一轮已创建但不可用的内核/插件进程 */ private fun releaseKernel() { - runCatching { - if (::box.isInitialized) box.close() - } - if (::processes.isInitialized) { - runCatching { processes.close(GlobalScope + Dispatchers.IO) } - } + // box / processes 是父类的 lateinit 属性,子类不能用 ::x.isInitialized 检查, + // 未初始化时访问会抛 UninitializedPropertyAccessException,runCatching 兜住即可 + runCatching { box.close() } + runCatching { processes.close(CoroutineScope(Dispatchers.IO)) } } } From 0d7452945bb2f865abad13bece06b02303dcbc86 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Sun, 8 Nov 2026 00:53:27 +0000 Subject: [PATCH 61/62] feat: add xhttp transport via sing-xhttp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 sing-box 没有 Xray 的 XHTTP(split-http)传输,xhttp 节点此前无法使用。 - patch-singbox.py 新增 patch_xhttp:注入 constant/option/transport 三处注册点, 并写入 option 适配层 transport/v2ray/xhttp.go - 传输实现使用 github.com/justinwoo280/sing-xhttp v0.1.6(不依赖 sing-box, 接口与 adapter.V2RayClientTransport / V2RayServerTransport 结构一致) - go.mod 声明依赖;quic-go 对齐 sing-xhttp 要求的 sing-box-mod.7 - 批量测速移除「xhttp 不支持」的静态剔除(内核已支持),保留 decode 失败的动态剔除 本地已验证:补丁在 1.14/1.15 源码上可重复执行且幂等; option/transport/v2ray/根包编译与 go vet 通过; App 侧生成的 xhttp 配置 JSON 能被内核 option 正确解码。 --- .../sagernet/bg/proto/BatchTestInstance.kt | 43 +--- buildScript/lib/core/patch-singbox.py | 190 ++++++++++++++++++ buildScript/lib/core/singbox-xhttp/xhttp.go | 100 +++++++++ libcore/go.mod | 2 +- 4 files changed, 296 insertions(+), 39 deletions(-) create mode 100644 buildScript/lib/core/singbox-xhttp/xhttp.go diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt index 2c5ead48d4..658e65ed8f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -3,10 +3,7 @@ package io.nekohasekai.sagernet.bg.proto import io.nekohasekai.sagernet.BuildConfig import io.nekohasekai.sagernet.bg.GuardedProcessPool import io.nekohasekai.sagernet.database.ProxyEntity -import io.nekohasekai.sagernet.database.SagerDatabase import io.nekohasekai.sagernet.fmt.buildConfig -import io.nekohasekai.sagernet.fmt.internal.ChainBean -import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.ktx.Logs import io.nekohasekai.sagernet.ktx.readableMessage import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher @@ -26,10 +23,8 @@ import kotlin.coroutines.suspendCoroutine * * 原实现([TestInstance])每个节点都要新建并启动一次内核,节点多时开销很大。 * - * 注意:整组共用一个配置,任何一个节点配置非法都会让整个内核起不来 - * (例如 xhttp 传输当前内核不支持,会报 "unknown transport type")。 - * 因此这里对单节点做了隔离:静态检查出内核明确不支持的节点先剔除, - * 动态 decode 失败时再按出错的 outbound 定位节点并剔除重试, + * 注意:整组共用一个配置,任何一个节点配置非法都会让整个内核起不来。 + * 因此这里对单节点做了隔离:decode 失败时按出错的 outbound 定位节点并剔除重试, * 被剔除的节点记在 [excluded] 里由调用方标记失败,避免整组显示不可用。 */ class BatchTestInstance( @@ -52,20 +47,9 @@ class BatchTestInstance( * 只需调用一次,之后用 [testTag] 并发测速。 */ suspend fun prepare() { - // 1) 静态剔除内核明确不支持的传输 - runOnDefaultDispatcher { - val usable = ArrayList() - for (profile in activeProfiles) { - val reason = unsupportedReason(profile) - if (reason == null) usable.add(profile) else excluded[profile.id] = reason - } - if (usable.isNotEmpty()) activeProfiles = usable - } - if (activeProfiles.isEmpty()) { - throw Exception("没有可测试的节点") - } - - // 2) 动态兜底:decode 失败时定位出错的节点,剔除后重建配置重试 + // 整组共用一个配置:任何一个节点配置非法都会让整个内核起不来, + // decode 失败时按出错的 outbound 定位到具体节点,剔除后重建配置重试, + // 避免一个坏节点让整组显示不可用。 while (true) { try { startOnce() @@ -120,23 +104,6 @@ class BatchTestInstance( box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl) } - /** 静态检查:节点(含链内节点)是否用了当前内核不支持的传输 */ - private fun unsupportedReason(profile: ProxyEntity): String? { - val bean = runCatching { profile.requireBean() }.getOrNull() ?: return null - if (bean is ChainBean) { - val beans = runCatching { SagerDatabase.proxyDao.getEntities(bean.proxies) } - .getOrNull() ?: return null - for (sub in beans) { - unsupportedReason(sub)?.let { return it } - } - return null - } - if (bean is StandardV2RayBean && bean.type == "xhttp") { - return "当前内核不支持 xhttp 传输" - } - return null - } - /** * 从 decode 错误里定位是哪个节点的 outbound 非法。 * sing-box 的报错形如 "decode config: outbounds[20].transport: unknown transport type: xhttp", diff --git a/buildScript/lib/core/patch-singbox.py b/buildScript/lib/core/patch-singbox.py index 7d87724504..197c49ae74 100644 --- a/buildScript/lib/core/patch-singbox.py +++ b/buildScript/lib/core/patch-singbox.py @@ -14,6 +14,7 @@ """ import pathlib +import subprocess import sys SRS_GO = '''package nekoutils @@ -242,6 +243,194 @@ def patch_vless_encryption(root: pathlib.Path) -> None: print("patch-singbox: protocol/vless/outbound.go patched") +XHTTP_BRIDGE_DIR = pathlib.Path(__file__).resolve().parent / "singbox-xhttp" +XHTTP_VERSION = "0.1.6" + +XHTTP_OPTION_TYPES = ''' +// V2RayXHTTPOptions 是 Xray XHTTP(split-http)传输的配置。 +// 字段名与 App 侧 SingBoxOptions.XHTTPOptions 的序列化结果保持一致, +// 实际传输实现由 github.com/justinwoo280/sing-xhttp 提供。 +type V2RayXHTTPOptions struct { + Mode string `json:"mode,omitempty"` + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + Headers badoption.HTTPHeader `json:"headers,omitempty"` + UplinkHTTPMethod string `json:"uplink_http_method,omitempty"` + NoGRPCHeader bool `json:"no_grpc_header,omitempty"` + NoSSEHeader bool `json:"no_sse_header,omitempty"` + XPaddingBytes *V2RayXHTTPRange `json:"x_padding_bytes,omitempty"` + ScMaxEachPostBytes *V2RayXHTTPRange `json:"sc_max_each_post_bytes,omitempty"` + ScMaxBufferedPosts int32 `json:"sc_max_buffered_posts,omitempty"` + ScMinPostsIntervalMs *V2RayXHTTPRange `json:"sc_min_posts_interval_ms,omitempty"` + ScStreamUpServerSecs *V2RayXHTTPRange `json:"sc_stream_up_server_secs,omitempty"` + XPaddingObfsMode bool `json:"x_padding_obfs_mode,omitempty"` + XPaddingPlacement string `json:"x_padding_placement,omitempty"` + XPaddingKey string `json:"x_padding_key,omitempty"` + XPaddingHeader string `json:"x_padding_header,omitempty"` + XPaddingMethod string `json:"x_padding_method,omitempty"` + SessionPlacement string `json:"session_placement,omitempty"` + SessionKey string `json:"session_key,omitempty"` + SessionIDTable string `json:"session_id_table,omitempty"` + SessionIDLength *V2RayXHTTPRange `json:"session_id_length,omitempty"` + SeqPlacement string `json:"seq_placement,omitempty"` + SeqKey string `json:"seq_key,omitempty"` + UplinkDataPlacement string `json:"uplink_data_placement,omitempty"` + UplinkDataKey string `json:"uplink_data_key,omitempty"` + UplinkChunkSize *V2RayXHTTPRange `json:"uplink_chunk_size,omitempty"` + ServerMaxHeaderBytes int32 `json:"server_max_header_bytes,omitempty"` + // App 侧可能下发但当前实现不使用的字段,保留以免严格解码失败 + CongestionController any `json:"congestion_controller,omitempty"` + Cwnd any `json:"cwnd,omitempty"` + Download any `json:"download,omitempty"` + Xmux *V2RayXHTTPXmuxOptions `json:"xmux,omitempty"` +} + +type V2RayXHTTPRange struct { + From int32 `json:"from,omitempty"` + To int32 `json:"to,omitempty"` +} + +type V2RayXHTTPXmuxOptions struct { + MaxConcurrency *V2RayXHTTPRange `json:"max_concurrency,omitempty"` + MaxConnections *V2RayXHTTPRange `json:"max_connections,omitempty"` + CMaxReuseTimes *V2RayXHTTPRange `json:"c_max_reuse_times,omitempty"` + HMaxRequestTimes *V2RayXHTTPRange `json:"h_max_request_times,omitempty"` + HMaxReusableSecs *V2RayXHTTPRange `json:"h_max_reusable_secs,omitempty"` + HKeepAlivePeriod int32 `json:"h_keep_alive_period,omitempty"` +} +''' + + +def patch_xhttp(root: pathlib.Path) -> None: + """接入 Xray XHTTP(split-http)传输。 + + 上游 sing-box 没有该传输。这里只补三处注册点,传输实现来自 + github.com/justinwoo280/sing-xhttp(不依赖 sing-box,接口结构一致), + 另加一层 option → xhttp.Options 的适配文件 transport/v2ray/xhttp.go。 + """ + bridge = XHTTP_BRIDGE_DIR / "xhttp.go" + if not bridge.is_file(): + fail("找不到 singbox-xhttp/xhttp.go,仓库不完整") + + # 1) constant/v2ray.go:类型常量 + path = root / "constant" / "v2ray.go" + text = path.read_text(encoding="utf-8") + if "V2RayTransportTypeXHTTP" in text: + print("patch-singbox: constant/v2ray.go 已打过补丁,跳过") + else: + anchor = '\tV2RayTransportTypeHTTPUpgrade = "httpupgrade"\n' + if anchor not in text: + fail("constant/v2ray.go 锚点未匹配,上游结构可能已变化") + text = text.replace(anchor, anchor + '\tV2RayTransportTypeXHTTP = "xhttp"\n', 1) + path.write_text(text, encoding="utf-8") + print("patch-singbox: constant/v2ray.go patched") + + # 2) option/v2ray_transport.go:选项类型 + 三个 switch 分支 + path = root / "option" / "v2ray_transport.go" + text = path.read_text(encoding="utf-8") + if "V2RayXHTTPOptions" in text: + print("patch-singbox: option/v2ray_transport.go 已打过补丁,跳过") + else: + edits = [ + ( + '\tHTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"`\n', + '\tHTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"`\n' + '\tXHTTPOptions V2RayXHTTPOptions `json:"-"`\n', + ), + ( + 'enum:"http,ws,quic,grpc,httpupgrade"', + 'enum:"http,ws,quic,grpc,httpupgrade,xhttp"', + ), + ( + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = o.HTTPUpgradeOptions\n', + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = o.HTTPUpgradeOptions\n' + '\tcase C.V2RayTransportTypeXHTTP:\n\t\tv = o.XHTTPOptions\n', + ), + ( + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = &o.HTTPUpgradeOptions\n', + '\tcase C.V2RayTransportTypeHTTPUpgrade:\n\t\tv = &o.HTTPUpgradeOptions\n' + '\tcase C.V2RayTransportTypeXHTTP:\n\t\tv = &o.XHTTPOptions\n', + ), + ( + '\t\t\t{Value: C.V2RayTransportTypeHTTPUpgrade, StructType: reflect.TypeFor[V2RayHTTPUpgradeOptions]()},\n', + '\t\t\t{Value: C.V2RayTransportTypeHTTPUpgrade, StructType: reflect.TypeFor[V2RayHTTPUpgradeOptions]()},\n' + '\t\t\t{Value: C.V2RayTransportTypeXHTTP, StructType: reflect.TypeFor[V2RayXHTTPOptions]()},\n', + ), + ] + for old, new in edits: + if old not in text: + fail("option/v2ray_transport.go 锚点未匹配: " + old.strip()[:48]) + text = text.replace(old, new, 1) + text = text.rstrip("\n") + "\n" + XHTTP_OPTION_TYPES + path.write_text(text, encoding="utf-8") + print("patch-singbox: option/v2ray_transport.go patched") + + # 3) transport/v2ray/transport.go:服务端/客户端分支 + path = root / "transport" / "v2ray" / "transport.go" + text = path.read_text(encoding="utf-8") + if "V2RayTransportTypeXHTTP" in text: + print("patch-singbox: transport/v2ray/transport.go 已打过补丁,跳过") + else: + server_anchor = ( + "\tcase C.V2RayTransportTypeHTTPUpgrade:\n" + "\t\treturn v2rayhttpupgrade.NewServer(ctx, logger, options.HTTPUpgradeOptions, tlsConfig, handler)\n" + ) + client_anchor = ( + "\tcase C.V2RayTransportTypeHTTPUpgrade:\n" + "\t\treturn v2rayhttpupgrade.NewClient(ctx, dialer, serverAddr, options.HTTPUpgradeOptions, tlsConfig)\n" + ) + if server_anchor not in text: + fail("transport/v2ray/transport.go 服务端锚点未匹配") + if client_anchor not in text: + fail("transport/v2ray/transport.go 客户端锚点未匹配") + text = text.replace( + server_anchor, + server_anchor + + "\tcase C.V2RayTransportTypeXHTTP:\n" + + "\t\treturn NewXHTTPServer(ctx, logger, options.XHTTPOptions, tlsConfig, handler)\n", + 1, + ) + text = text.replace( + client_anchor, + client_anchor + + "\tcase C.V2RayTransportTypeXHTTP:\n" + + "\t\treturn NewXHTTPClient(ctx, dialer, serverAddr, options.XHTTPOptions, tlsConfig)\n", + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: transport/v2ray/transport.go patched") + + # 4) 适配层文件 + (root / "transport" / "v2ray" / "xhttp.go").write_bytes(bridge.read_bytes()) + + # 5) go.mod:声明外部依赖(go.sum 由构建时的 -mod=mod 补全) + path = root / "go.mod" + text = path.read_text(encoding="utf-8") + if "justinwoo280/sing-xhttp" not in text: + anchor = "\nrequire (\n" + if anchor not in text: + fail("go.mod 找不到 require 块") + text = text.replace( + anchor, + anchor + "\tgithub.com/justinwoo280/sing-xhttp v" + XHTTP_VERSION + "\n", + 1, + ) + path.write_text(text, encoding="utf-8") + print("patch-singbox: go.mod patched") + + # 6) 尽力格式化改动的文件(gofmt 不可用时跳过) + for target in ( + root / "constant" / "v2ray.go", + root / "option" / "v2ray_transport.go", + root / "transport" / "v2ray" / "transport.go", + root / "transport" / "v2ray" / "xhttp.go", + ): + try: + subprocess.run(["gofmt", "-w", str(target)], capture_output=True, check=False) + except OSError: + break + + def main() -> None: if len(sys.argv) < 2: fail("缺少 sing-box 源码目录参数") @@ -257,6 +446,7 @@ def main() -> None: patch_rule_set_local(root) patch_selector(root) patch_vless_encryption(root) + patch_xhttp(root) print("patch-singbox: done") diff --git a/buildScript/lib/core/singbox-xhttp/xhttp.go b/buildScript/lib/core/singbox-xhttp/xhttp.go new file mode 100644 index 0000000000..e67d0c5c63 --- /dev/null +++ b/buildScript/lib/core/singbox-xhttp/xhttp.go @@ -0,0 +1,100 @@ +package v2ray + +import ( + "context" + + "github.com/justinwoo280/sing-xhttp/xhttp" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/tls" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +// Xray XHTTP(split-http)传输的 sing-box 适配层。 +// +// 实现来自 github.com/justinwoo280/sing-xhttp:该库不依赖 sing-box, +// 自行声明了与 adapter.V2RayClientTransport / V2RayServerTransport +// 结构完全一致的接口,所以这里只做 option → xhttp.Options 的字段映射。 + +func toXHTTPRange(value *option.V2RayXHTTPRange) *xhttp.Range { + if value == nil { + return nil + } + return &xhttp.Range{From: value.From, To: value.To} +} + +func toXHTTPOptions(options option.V2RayXHTTPOptions) xhttp.Options { + converted := xhttp.Options{ + Mode: options.Mode, + Host: options.Host, + Path: options.Path, + Method: options.UplinkHTTPMethod, + Headers: options.Headers, + NoGRPCHeader: options.NoGRPCHeader, + NoSSEHeader: options.NoSSEHeader, + XPaddingBytes: toXHTTPRange(options.XPaddingBytes), + ScMaxEachPostBytes: toXHTTPRange(options.ScMaxEachPostBytes), + ScMaxBufferedPosts: options.ScMaxBufferedPosts, + ScMinPostsIntervalMs: toXHTTPRange(options.ScMinPostsIntervalMs), + ScStreamUpServerSecs: toXHTTPRange(options.ScStreamUpServerSecs), + XPaddingObfsMode: options.XPaddingObfsMode, + XPaddingPlacement: options.XPaddingPlacement, + XPaddingKey: options.XPaddingKey, + XPaddingHeader: options.XPaddingHeader, + XPaddingMethod: options.XPaddingMethod, + SessionPlacement: options.SessionPlacement, + SessionKey: options.SessionKey, + SessionIDTable: options.SessionIDTable, + SessionIDLength: toXHTTPRange(options.SessionIDLength), + SeqPlacement: options.SeqPlacement, + SeqKey: options.SeqKey, + UplinkDataPlacement: options.UplinkDataPlacement, + UplinkDataKey: options.UplinkDataKey, + UplinkChunkSize: toXHTTPRange(options.UplinkChunkSize), + ServerMaxHeaderBytes: options.ServerMaxHeaderBytes, + } + // App 侧下发的独立下载配置目前只取 host/path(与上游实现的子集对齐) + if download, loaded := options.Download.(map[string]any); loaded { + downloadConfig := &xhttp.DownloadConfig{} + if host, loaded := download["host"].(string); loaded { + downloadConfig.Host = host + } + if path, loaded := download["path"].(string); loaded { + downloadConfig.Path = path + } + converted.DownloadSettings = downloadConfig + } + if options.Xmux != nil { + converted.Xmux = &xhttp.XmuxConfig{ + MaxConcurrency: toXHTTPRange(options.Xmux.MaxConcurrency), + MaxConnections: toXHTTPRange(options.Xmux.MaxConnections), + CMaxReuseTimes: toXHTTPRange(options.Xmux.CMaxReuseTimes), + HMaxRequestTimes: toXHTTPRange(options.Xmux.HMaxRequestTimes), + HMaxReusableSecs: toXHTTPRange(options.Xmux.HMaxReusableSecs), + HKeepAlivePeriod: options.Xmux.HKeepAlivePeriod, + } + } + return converted +} + +func NewXHTTPClient( + ctx context.Context, + dialer N.Dialer, + serverAddr M.Socksaddr, + options option.V2RayXHTTPOptions, + tlsConfig tls.Config, +) (adapter.V2RayClientTransport, error) { + return xhttp.NewClient(ctx, dialer, serverAddr, toXHTTPOptions(options), tlsConfig) +} + +func NewXHTTPServer( + ctx context.Context, + logger logger.ContextLogger, + options option.V2RayXHTTPOptions, + tlsConfig tls.ServerConfig, + handler adapter.V2RayServerTransportHandler, +) (adapter.V2RayServerTransport, error) { + return xhttp.NewServer(ctx, logger, toXHTTPOptions(options), tlsConfig, handler) +} diff --git a/libcore/go.mod b/libcore/go.mod index 464f118246..a1cee873a6 100644 --- a/libcore/go.mod +++ b/libcore/go.mod @@ -8,7 +8,7 @@ require ( github.com/matsuridayo/libneko v1.0.0 // replaced github.com/miekg/dns v1.1.67 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/sagernet/quic-go v0.61.0-sing-box-mod.6 + github.com/sagernet/quic-go v0.61.0-sing-box-mod.7 github.com/sagernet/sing v0.9.0-beta.4 github.com/sagernet/sing-box v1.0.0 // replaced github.com/sagernet/sing-tun v0.9.0-beta.4 From d2756579b9a737b8d6f6735f61e8acee15b6e839 Mon Sep 17 00:00:00 2001 From: yekaipow Date: Sun, 8 Nov 2026 01:07:11 +0000 Subject: [PATCH 62/62] fix: normalize uTLS fingerprint to what the kernel supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订阅里的 fingerprint=unsafe(Xray 表示禁用 uTLS)会原样写入配置, sing-box 初始化时直接报 unknown uTLS fingerprint,导致该节点不可用; 批量测速时还会让整组内核起不来。 - 指纹统一小写并只透传内核认识的 10 种(chrome/firefox/edge/safari/360/qq/ios/android/random/randomized) - 未知值按 unsafe 语义处理:不启用 uTLS,走标准 TLS - REALITY 必须启用 uTLS,未知指纹退回 chrome - 批量测速的错误定位正则同时兼容 outbound[N](初始化阶段)与 outbounds[N](decode 阶段) --- .../sagernet/bg/proto/BatchTestInstance.kt | 3 ++- .../nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt index 658e65ed8f..153cc3aeed 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BatchTestInstance.kt @@ -113,7 +113,8 @@ class BatchTestInstance( val message = generateSequence(e) { it.cause } .mapNotNull { it.message } .joinToString(" | ") - val index = Regex("""outbounds\[(\d+)]""").find(message) + // 兼容 "outbounds[20]"(decode 阶段)和 "outbound[1]"(初始化阶段) + val index = Regex("""outbounds?\[(\d+)]""").find(message) ?.groupValues?.get(1)?.toIntOrNull() ?: return null val tag = runCatching { JSONObject(config.config).getJSONArray("outbounds").getJSONObject(index).optString("tag") diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt index b1eebb44d9..5911c564b7 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt @@ -17,6 +17,11 @@ private val supportedKcpHeaderType = arrayOf( "none", "srtp", "utp", "wechat-video", "dtls", "wireguard", "dns" ) +/** sing-box common/tls 实际支持的 uTLS 指纹(小写) */ +private val knownUTLSFingerprints = setOf( + "chrome", "firefox", "edge", "safari", "360", "qq", "ios", "android", "random", "randomized" +) + data class VmessQRCode( var v: String = "", var ps: String = "", @@ -790,20 +795,27 @@ fun buildSingBoxOutboundTLS(bean: StandardV2RayBean): OutboundTLSOptions? { } } if (bean.certificates.isNotBlank()) certificate = bean.certificates - var fp = bean.utlsFingerprint + var fp = bean.utlsFingerprint?.trim()?.lowercase().orEmpty() + // sing-box 的 uTLS 只认这些指纹。Xray 的 "unsafe" 表示禁用 uTLS, + // 直接透传会让内核初始化失败(unknown uTLS fingerprint), + // 进而整组测速/连接全部失败。 if (bean.realityPubKey.isNotBlank()) { reality = OutboundRealityOptions().apply { enabled = true public_key = bean.realityPubKey short_id = bean.realityShortId } - if (fp.isNullOrBlank()) fp = "chrome" + // REALITY 必须启用 uTLS,未知指纹退回最通用的 chrome + if (fp !in knownUTLSFingerprints) fp = "chrome" } - if (fp.isNotBlank()) { + if (fp in knownUTLSFingerprints) { utls = OutboundUTLSOptions().apply { enabled = true fingerprint = fp } + } else if (fp.isNotEmpty()) { + // 未知指纹按「不启用 uTLS」处理,即 Xray 的 unsafe 语义 + Logs.w("unsupported uTLS fingerprint \"$fp\", falling back to default TLS") } if (bean.enableECH) { ech = OutboundECHOptions().apply {