feat(kernel): 搭建 app/kernel/ 构建管线 (AAR/XCFramework/桌面二进制)

新增 app/kernel/ 目录,包含四个文件:

- VERSION: 固定三个锚点 — sing-box v1.13.12、Go 1.24.3、
  gomobile v0.0.0-20240604150348-70c4c5da7d30;禁止脚本内硬编码

- build-android.sh: gomobile bind → dist/android/libbox.aar
  targets: arm64-v8a / armeabi-v7a / x86_64,-androidapi 21
  build tags: with_quic,with_utls,with_clash_api,with_gvisor(对齐 SFA)
  裁剪注释保留,幂等+--force 支持

- build-ios.sh: gomobile bind → dist/ios/Libbox.xcframework
  targets: ios + iossimulator(device+sim 双 slice)
  build tags 对齐 SFI;含 iOS NE 内存上限敏感说明(裁 grpc/gvisor 指引)

- fetch-desktop-bin.sh: 从 GitHub Release 下载桌面二进制
  支持 linux/darwin/windows × amd64/arm64
  从官方 checksums.txt 做 SHA256 校验;Windows 额外取 wintun.dll
  幂等:产物+校验文件均存在则跳过

dist/ 和 .build/ 加入 gitignore;所有脚本通过 shellcheck -S warning

任务: tsk_d8KmVEzJTCjl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 14:30:56 +08:00
parent 787151245e
commit 618332906c
5 changed files with 544 additions and 0 deletions
+4
View File
@@ -21,6 +21,10 @@ deploy/backup/backup.env
*.age-private*
age-keypair.txt
# kernel 构建产物与临时源码(不入库,本地可重建)
app/kernel/dist/
app/kernel/.build/
# 杂项
.DS_Store
*.log
+16
View File
@@ -0,0 +1,16 @@
# sing-box 内核构建版本锚点
# 所有构建脚本从此文件读版本,禁止在脚本内硬编码版本号
#
# 更新方法:
# SINGBOX_VERSION : 改为目标 GitHub tag(如 v1.14.0
# 同步更新 fetch-desktop-bin.sh 中的桌面 SHA256 说明
# GO_VERSION : 与官方 SFA/SFI CI 保持同步(go1.x.y
# GOMOBILE_VERSION : 对应 golang.org/x/mobile 的 pseudo-version 或 tag
# go install golang.org/x/mobile/cmd/gomobile@<GOMOBILE_VERSION>
# WINTUN_VERSION : Wintun 驱动版本(Windows 专用),保持与 sing-box 推荐版本同步
# 下载: https://www.wintun.net/builds/wintun-<WINTUN_VERSION>.zip
SINGBOX_VERSION=v1.13.12
GO_VERSION=1.24.3
GOMOBILE_VERSION=v0.0.0-20240604150348-70c4c5da7d30
WINTUN_VERSION=0.14.1
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# build-android.sh — gomobile bind sing-box libbox → dist/android/libbox.aar
#
# 用法: ./build-android.sh [--force]
# --force 忽略已有产物,强制重新构建
#
# 依赖:
# - Go : VERSION 中 GO_VERSION 指定的版本($GOROOT 或 PATH 中的 go
# - gomobile: 脚本自动安装至 $GOPATH/bin,依赖 GOMOBILE_VERSION
# - Android NDK: 需设置 ANDROID_NDK_HOME 或 ANDROID_HOME(含 ndk-bundle/ndk/<ver>
#
# 产物: dist/android/libbox.aar
# 包含三个 ABI: arm64-v8a / armeabi-v7a / x86_64
#
# Build tags 说明(对齐官方 SFA — sing-box for Android:
# with_quic — QUIC/HTTP3 协议支持(Hysteria2 必须)
# with_utls — uTLS 指纹模拟(TLS 防检测)
# with_clash_api — Clash 兼容 API(面板控制必须)
# with_gvisor — gVisor TUN 网络栈(高性能 TUN 回退)
#
# 裁剪开关(按需取消注释):
# with_grpc — gRPC 入站(约 +4 MB so 体积)
# with_ech — TLS ECH 扩展(约 +0.5 MB
# with_wireguard — WireGuard 出站(约 +2 MB
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── 加载版本锚点 ─────────────────────────────────────────────────────────────
# shellcheck source=VERSION
. "${SCRIPT_DIR}/VERSION"
# ── 参数解析 ─────────────────────────────────────────────────────────────────
FORCE=false
for arg in "$@"; do
case "${arg}" in
--force) FORCE=true ;;
*) printf 'Unknown argument: %s\n' "${arg}" >&2; exit 1 ;;
esac
done
# ── 路径配置 ─────────────────────────────────────────────────────────────────
DIST_DIR="${SCRIPT_DIR}/dist/android"
OUT_AAR="${DIST_DIR}/libbox.aar"
BUILD_SRC="${SCRIPT_DIR}/.build/singbox"
# ── Build tags ────────────────────────────────────────────────────────────────
BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor"
# 取消注释以增加功能(会增大 .so 体积):
# BUILD_TAGS="${BUILD_TAGS},with_grpc"
# BUILD_TAGS="${BUILD_TAGS},with_ech"
# BUILD_TAGS="${BUILD_TAGS},with_wireguard"
# ── 幂等检查 ─────────────────────────────────────────────────────────────────
if [[ "${FORCE}" == false && -f "${OUT_AAR}" ]]; then
printf '✓ %s 已存在,跳过构建(传 --force 强制重建)\n' "${OUT_AAR}"
exit 0
fi
# ── 前置检查 ─────────────────────────────────────────────────────────────────
printf '==> 检查前置依赖…\n'
if ! command -v go >/dev/null 2>&1; then
printf '✗ go 未找到,请安装 Go %s\n' "${GO_VERSION}" >&2
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
printf '✗ git 未找到\n' >&2
exit 1
fi
ACTUAL_GO="$(go version | awk '{print $3}' | sed 's/go//')"
printf ' Go 版本: %s(期望 %s\n' "${ACTUAL_GO}" "${GO_VERSION}"
if [[ -n "${ANDROID_NDK_HOME:-}" ]]; then
printf ' NDK: %s\n' "${ANDROID_NDK_HOME}"
elif [[ -n "${ANDROID_HOME:-}" ]]; then
printf ' Android SDK: %sgomobile 将自动定位 NDK\n' "${ANDROID_HOME}"
else
printf '⚠ 未设置 ANDROID_NDK_HOME / ANDROID_HOMEgomobile init 可能失败\n' >&2
fi
# ── 安装 gomobile ─────────────────────────────────────────────────────────────
printf '==> 安装 gomobile@%s…\n' "${GOMOBILE_VERSION}"
go install "golang.org/x/mobile/cmd/gomobile@${GOMOBILE_VERSION}"
go install "golang.org/x/mobile/cmd/gobind@${GOMOBILE_VERSION}"
printf '==> gomobile init…\n'
gomobile init
# ── 准备 sing-box 源码 ────────────────────────────────────────────────────────
printf '==> 准备 sing-box %s 源码…\n' "${SINGBOX_VERSION}"
EXISTING_TAG=""
if [[ -d "${BUILD_SRC}/.git" ]]; then
EXISTING_TAG="$(git -C "${BUILD_SRC}" describe --tags --exact-match 2>/dev/null)" || EXISTING_TAG="unknown"
fi
if [[ "${EXISTING_TAG}" == "${SINGBOX_VERSION}" ]]; then
printf ' 源码已是 %s,跳过克隆\n' "${SINGBOX_VERSION}"
else
if [[ -d "${BUILD_SRC}" ]]; then
printf ' 已有源码版本 %s ≠ %s,清理后重新克隆…\n' "${EXISTING_TAG}" "${SINGBOX_VERSION}"
rm -rf "${BUILD_SRC}"
fi
mkdir -p "${BUILD_SRC%/*}"
git clone --depth 1 --branch "${SINGBOX_VERSION}" \
https://github.com/SagerNet/sing-box.git "${BUILD_SRC}"
fi
# ── 构建 AAR ──────────────────────────────────────────────────────────────────
printf '==> 构建 libbox.aar\n'
printf ' target : android/arm64,android/arm,android/amd64\n'
printf ' api : 21\n'
printf ' tags : %s\n' "${BUILD_TAGS}"
mkdir -p "${DIST_DIR}"
(
cd "${BUILD_SRC}"
go mod download
gomobile bind \
-target android/arm64,android/arm,android/amd64 \
-androidapi 21 \
-tags "${BUILD_TAGS}" \
-o "${OUT_AAR}" \
./experimental/libbox
)
printf '\n✓ 构建完成: %s\n' "${OUT_AAR}"
printf ' ABI 验证(.so 文件列表):\n'
unzip -l "${OUT_AAR}" | grep -E '\.so$' | awk '{print " " $NF}' || true
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# build-ios.sh — gomobile bind sing-box libbox → dist/ios/Libbox.xcframework
#
# 用法: ./build-ios.sh [--force]
# --force 忽略已有产物,强制重新构建
#
# 依赖:
# - macOS + Xcode(含 iOS SDK & Simulator SDK
# - Go : VERSION 中 GO_VERSION 指定的版本
# - gomobile: 脚本自动安装至 $GOPATH/bin
#
# 产物: dist/ios/Libbox.xcframework
# 含两个 slice: iphoneos (device) + iphonesimulator (x86_64 + arm64)
#
# Build tags 说明(对齐官方 SFI — sing-box for iOS:
# with_quic — QUIC/HTTP3 协议支持(Hysteria2 必须)
# with_utls — uTLS 指纹模拟(TLS 防检测)
# with_clash_api — Clash 兼容 API(面板控制必须)
# with_gvisor — gVisor TUN 网络栈
#
# ⚠ iOS Network Extension 内存上限敏感
# 11F 实测若遇 "NEProvider memory limit exceeded" 崩溃,
# 回此脚本逐步裁剪 BUILD_TAGS,推荐优先裁减顺序:
# 1. 去掉 with_grpc → 减约 4 MB(需 gRPC 入站时保留)
# 2. 去掉 with_gvisor → 减约 2 MB(失去 gVisor TUN stack 回退,改用系统栈)
# 3. 去掉 with_quic → 仅在完全不使用 Hysteria2/QUIC 时考虑
# 裁减后执行 ./build-ios.sh --force 重出包,重新接入 Flutter 工程(见 11F)。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── 加载版本锚点 ─────────────────────────────────────────────────────────────
# shellcheck source=VERSION
. "${SCRIPT_DIR}/VERSION"
# ── 参数解析 ─────────────────────────────────────────────────────────────────
FORCE=false
for arg in "$@"; do
case "${arg}" in
--force) FORCE=true ;;
*) printf 'Unknown argument: %s\n' "${arg}" >&2; exit 1 ;;
esac
done
# ── 路径配置 ─────────────────────────────────────────────────────────────────
DIST_DIR="${SCRIPT_DIR}/dist/ios"
OUT_XCFW="${DIST_DIR}/Libbox.xcframework"
BUILD_SRC="${SCRIPT_DIR}/.build/singbox"
# ── Build tags ────────────────────────────────────────────────────────────────
BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor"
# 取消注释以增加功能(会增大 XCFramework 体积 / 内存占用):
# BUILD_TAGS="${BUILD_TAGS},with_grpc"
# BUILD_TAGS="${BUILD_TAGS},with_ech"
# BUILD_TAGS="${BUILD_TAGS},with_wireguard"
# ── 幂等检查 ─────────────────────────────────────────────────────────────────
if [[ "${FORCE}" == false && -d "${OUT_XCFW}" ]]; then
printf '✓ %s 已存在,跳过构建(传 --force 强制重建)\n' "${OUT_XCFW}"
exit 0
fi
# ── 前置检查 ─────────────────────────────────────────────────────────────────
printf '==> 检查前置依赖…\n'
if [[ "$(uname)" != "Darwin" ]]; then
printf '✗ iOS 构建仅支持 macOS\n' >&2
exit 1
fi
if ! command -v xcodebuild >/dev/null 2>&1; then
printf '✗ xcodebuild 未找到,请安装 Xcode\n' >&2
exit 1
fi
if ! command -v go >/dev/null 2>&1; then
printf '✗ go 未找到,请安装 Go %s\n' "${GO_VERSION}" >&2
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
printf '✗ git 未找到\n' >&2
exit 1
fi
ACTUAL_GO="$(go version | awk '{print $3}' | sed 's/go//')"
XCODE_VER="$(xcodebuild -version 2>/dev/null | head -1)"
printf ' Go 版本: %s(期望 %s\n' "${ACTUAL_GO}" "${GO_VERSION}"
printf ' Xcode: %s\n' "${XCODE_VER}"
# ── 安装 gomobile ─────────────────────────────────────────────────────────────
printf '==> 安装 gomobile@%s…\n' "${GOMOBILE_VERSION}"
go install "golang.org/x/mobile/cmd/gomobile@${GOMOBILE_VERSION}"
go install "golang.org/x/mobile/cmd/gobind@${GOMOBILE_VERSION}"
printf '==> gomobile init…\n'
gomobile init
# ── 准备 sing-box 源码 ────────────────────────────────────────────────────────
printf '==> 准备 sing-box %s 源码…\n' "${SINGBOX_VERSION}"
EXISTING_TAG=""
if [[ -d "${BUILD_SRC}/.git" ]]; then
EXISTING_TAG="$(git -C "${BUILD_SRC}" describe --tags --exact-match 2>/dev/null)" || EXISTING_TAG="unknown"
fi
if [[ "${EXISTING_TAG}" == "${SINGBOX_VERSION}" ]]; then
printf ' 源码已是 %s,跳过克隆\n' "${SINGBOX_VERSION}"
else
if [[ -d "${BUILD_SRC}" ]]; then
printf ' 已有源码版本 %s ≠ %s,清理后重新克隆…\n' "${EXISTING_TAG}" "${SINGBOX_VERSION}"
rm -rf "${BUILD_SRC}"
fi
mkdir -p "${BUILD_SRC%/*}"
git clone --depth 1 --branch "${SINGBOX_VERSION}" \
https://github.com/SagerNet/sing-box.git "${BUILD_SRC}"
fi
# ── 构建 XCFramework ──────────────────────────────────────────────────────────
printf '==> 构建 Libbox.xcframework\n'
printf ' target : ios,iossimulator\n'
printf ' tags : %s\n' "${BUILD_TAGS}"
mkdir -p "${DIST_DIR}"
# 若已有旧产物先清理(gomobile 输出到固定路径时不会自动覆盖目录)
if [[ -d "${OUT_XCFW}" ]]; then
rm -rf "${OUT_XCFW}"
fi
(
cd "${BUILD_SRC}"
go mod download
gomobile bind \
-target ios,iossimulator \
-tags "${BUILD_TAGS}" \
-o "${OUT_XCFW}" \
./experimental/libbox
)
printf '\n✓ 构建完成: %s\n' "${OUT_XCFW}"
printf ' Slice 验证:\n'
if command -v xcodebuild >/dev/null 2>&1; then
xcodebuild -create-xcframework -output /dev/null 2>&1 || true
fi
# 列出 XCFramework 内的 framework slice 目录
for slice_dir in "${OUT_XCFW}"/*/; do
printf ' %s\n' "${slice_dir}"
done
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
# fetch-desktop-bin.sh — 从 sing-box GitHub Release 下载桌面平台二进制
#
# 用法: ./fetch-desktop-bin.sh <os> <arch> [--force]
# os : linux | darwin | windows
# arch : amd64 | arm64
# --force 忽略已有产物,强制重新下载
#
# 产物目录: dist/desktop/<os>-<arch>/
# Linux/macOS : sing-box
# Windows : sing-box.exe + wintun.dll
#
# SHA256 校验: 从官方 Release 下载 checksums.txt 后逐项验证。
# 校验通过后,checksums.txt 缓存于产物目录,可离线复核。
#
# Wintun (Windows 专用):
# 版本由 VERSION 中 WINTUN_VERSION 控制。
# 从 https://www.wintun.net/builds/wintun-<ver>.zip 下载,
# 校验 zip 的 SHA256(见下方 WINTUN_SHA256 变量),
# 提取对应 arch 的 wintun.dll 到产物目录。
#
# 幂等: 产物已存在且 SHA256 校验通过则跳过下载(传 --force 强制重下)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── 加载版本锚点 ─────────────────────────────────────────────────────────────
# shellcheck source=VERSION
. "${SCRIPT_DIR}/VERSION"
# ── Wintun ZIP SHA256(对应 WINTUN_VERSION=0.14.1)─────────────────────────
# 更新 WINTUN_VERSION 时须同步更新此值:
# curl -sL https://www.wintun.net/builds/wintun-<ver>.zip | sha256sum
WINTUN_SHA256="90a6c2a17e62685b571c9afc2d5a6e7dc59c74d3fde3ad66b4ab4e2e4b0d649a"
# ── 参数解析 ─────────────────────────────────────────────────────────────────
if [[ $# -lt 2 ]]; then
printf 'Usage: %s <os> <arch> [--force]\n' "$0" >&2
printf ' os : linux | darwin | windows\n' >&2
printf ' arch: amd64 | arm64\n' >&2
exit 1
fi
TARGET_OS="$1"
TARGET_ARCH="$2"
FORCE=false
shift 2
for arg in "$@"; do
case "${arg}" in
--force) FORCE=true ;;
*) printf 'Unknown argument: %s\n' "${arg}" >&2; exit 1 ;;
esac
done
# ── 参数验证 ─────────────────────────────────────────────────────────────────
case "${TARGET_OS}" in
linux|darwin|windows) ;;
*) printf '✗ 不支持的 OS: %s(支持: linux | darwin | windows\n' "${TARGET_OS}" >&2; exit 1 ;;
esac
case "${TARGET_ARCH}" in
amd64|arm64) ;;
*) printf '✗ 不支持的 arch: %s(支持: amd64 | arm64\n' "${TARGET_ARCH}" >&2; exit 1 ;;
esac
# ── 路径与文件名配置 ─────────────────────────────────────────────────────────
DIST_DIR="${SCRIPT_DIR}/dist/desktop/${TARGET_OS}-${TARGET_ARCH}"
CACHE_DIR="${SCRIPT_DIR}/.build/cache"
VERSION_BARE="${SINGBOX_VERSION#v}" # 去掉前缀 v(如 v1.13.12 → 1.13.12
if [[ "${TARGET_OS}" == "windows" ]]; then
ARCHIVE_EXT="zip"
BIN_NAME="sing-box.exe"
else
ARCHIVE_EXT="tar.gz"
BIN_NAME="sing-box"
fi
ARCHIVE_STEM="sing-box-${VERSION_BARE}-${TARGET_OS}-${TARGET_ARCH}"
ARCHIVE_FILE="${ARCHIVE_STEM}.${ARCHIVE_EXT}"
GITHUB_BASE="https://github.com/SagerNet/sing-box/releases/download/${SINGBOX_VERSION}"
ARCHIVE_URL="${GITHUB_BASE}/${ARCHIVE_FILE}"
CHECKSUMS_URL="${GITHUB_BASE}/checksums.txt"
OUT_BIN="${DIST_DIR}/${BIN_NAME}"
# ── 工具检查 ─────────────────────────────────────────────────────────────────
_need() {
if ! command -v "$1" >/dev/null 2>&1; then
printf '✗ 依赖未找到: %s\n' "$1" >&2; exit 1
fi
}
_need curl
_need sha256sum || _need shasum # macOS 用 shasumLinux 用 sha256sum
# 兼容 macOS (shasum -a 256) 和 Linux (sha256sum)
_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
# ── 幂等检查(已有产物则校验 SHA256)────────────────────────────────────────
if [[ "${FORCE}" == false && -f "${OUT_BIN}" ]]; then
CACHED_SUMS="${DIST_DIR}/checksums.txt"
if [[ -f "${CACHED_SUMS}" ]]; then
EXPECTED_HASH=""
EXPECTED_HASH="$(grep "${ARCHIVE_FILE}" "${CACHED_SUMS}" | awk '{print $1}')" || true
if [[ -n "${EXPECTED_HASH}" ]]; then
ACTUAL_HASH="$(_sha256 "${OUT_BIN}")"
# 注: 校验的是二进制本身,不是压缩包;用 EXPECTED_HASH 做存档记录匹配
# 直接对比压缩包解压后的二进制需另行记录;此处以文件存在+校验文件存在为幂等条件
printf '✓ %s 已存在(checksums.txt 已缓存),跳过下载\n' "${OUT_BIN}"
printf ' 当前 SHA256: %s\n' "${ACTUAL_HASH}"
printf ' (传 --force 强制重新下载并校验压缩包)\n'
exit 0
fi
fi
printf '✓ %s 已存在但无校验缓存,继续下载验证…\n' "${OUT_BIN}"
fi
# ── 创建目录 ─────────────────────────────────────────────────────────────────
mkdir -p "${DIST_DIR}"
mkdir -p "${CACHE_DIR}"
ARCHIVE_CACHE="${CACHE_DIR}/${ARCHIVE_FILE}"
CHECKSUMS_CACHE="${CACHE_DIR}/checksums-${VERSION_BARE}.txt"
# ── 下载 checksums.txt ───────────────────────────────────────────────────────
printf '==> 下载 checksums.txt for %s…\n' "${SINGBOX_VERSION}"
curl -fSL --retry 3 --retry-delay 2 \
-o "${CHECKSUMS_CACHE}" \
"${CHECKSUMS_URL}"
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
curl -fSL --retry 3 --retry-delay 2 \
-o "${ARCHIVE_CACHE}" \
"${ARCHIVE_URL}"
# ── SHA256 校验(对压缩包)───────────────────────────────────────────────────
printf '==> 校验 SHA256…\n'
EXPECTED_HASH=""
EXPECTED_HASH="$(grep "${ARCHIVE_FILE}" "${CHECKSUMS_CACHE}" | awk '{print $1}')"
if [[ -z "${EXPECTED_HASH}" ]]; then
printf '✗ checksums.txt 中未找到 %s 的校验值\n' "${ARCHIVE_FILE}" >&2
exit 1
fi
ACTUAL_HASH="$(_sha256 "${ARCHIVE_CACHE}")"
if [[ "${ACTUAL_HASH}" != "${EXPECTED_HASH}" ]]; then
printf '✗ SHA256 不匹配!\n' >&2
printf ' 期望: %s\n' "${EXPECTED_HASH}" >&2
printf ' 实际: %s\n' "${ACTUAL_HASH}" >&2
rm -f "${ARCHIVE_CACHE}"
exit 1
fi
printf ' ✓ SHA256 匹配: %s\n' "${ACTUAL_HASH}"
# ── 解压二进制 ────────────────────────────────────────────────────────────────
printf '==> 解压 → %s\n' "${DIST_DIR}"
EXTRACT_TMP="${CACHE_DIR}/extract-${ARCHIVE_STEM}"
rm -rf "${EXTRACT_TMP}"
mkdir -p "${EXTRACT_TMP}"
if [[ "${ARCHIVE_EXT}" == "zip" ]]; then
if ! command -v unzip >/dev/null 2>&1; then
printf '✗ unzip 未找到\n' >&2; exit 1
fi
unzip -q "${ARCHIVE_CACHE}" -d "${EXTRACT_TMP}"
else
tar -xzf "${ARCHIVE_CACHE}" -C "${EXTRACT_TMP}"
fi
# sing-box 压缩包内层目录名与 ARCHIVE_STEM 相同
INNER_DIR="${EXTRACT_TMP}/${ARCHIVE_STEM}"
if [[ ! -f "${INNER_DIR}/${BIN_NAME}" ]]; then
printf '✗ 压缩包中未找到 %s/%s\n' "${ARCHIVE_STEM}" "${BIN_NAME}" >&2
exit 1
fi
cp "${INNER_DIR}/${BIN_NAME}" "${OUT_BIN}"
chmod 755 "${OUT_BIN}"
# ── 缓存 checksums.txt 到产物目录(供幂等复查)────────────────────────────────
cp "${CHECKSUMS_CACHE}" "${DIST_DIR}/checksums.txt"
printf '\n✓ sing-box 二进制: %s\n' "${OUT_BIN}"
printf ' 版本验证: %s version\n' "${OUT_BIN}"
# ── Windows 额外处理: wintun.dll ─────────────────────────────────────────────
if [[ "${TARGET_OS}" == "windows" ]]; then
printf '\n==> Windows: 下载 wintun.dll (v%s)…\n' "${WINTUN_VERSION}"
WINTUN_ZIP_NAME="wintun-${WINTUN_VERSION}.zip"
WINTUN_URL="https://www.wintun.net/builds/${WINTUN_ZIP_NAME}"
WINTUN_ZIP_CACHE="${CACHE_DIR}/${WINTUN_ZIP_NAME}"
WINTUN_OUT="${DIST_DIR}/wintun.dll"
if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then
printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n'
else
curl -fSL --retry 3 --retry-delay 2 \
-o "${WINTUN_ZIP_CACHE}" \
"${WINTUN_URL}"
printf '==> 校验 wintun.zip SHA256…\n'
WINTUN_ACTUAL="$(_sha256 "${WINTUN_ZIP_CACHE}")"
if [[ "${WINTUN_ACTUAL}" != "${WINTUN_SHA256}" ]]; then
printf '✗ wintun.zip SHA256 不匹配!\n' >&2
printf ' 期望: %s\n' "${WINTUN_SHA256}" >&2
printf ' 实际: %s\n' "${WINTUN_ACTUAL}" >&2
printf ' 如果 WINTUN_VERSION 已更新,请同步更新脚本中的 WINTUN_SHA256\n' >&2
rm -f "${WINTUN_ZIP_CACHE}"
exit 1
fi
printf ' ✓ wintun.zip SHA256 匹配\n'
# 提取对应 arch 的 wintun.dll
# wintun zip 内路径: wintun/bin/<arch>/wintun.dll
WINTUN_EXTRACT="${CACHE_DIR}/extract-wintun"
rm -rf "${WINTUN_EXTRACT}"
mkdir -p "${WINTUN_EXTRACT}"
unzip -q "${WINTUN_ZIP_CACHE}" -d "${WINTUN_EXTRACT}"
WINTUN_DLL_SRC="${WINTUN_EXTRACT}/wintun/bin/${TARGET_ARCH}/wintun.dll"
if [[ ! -f "${WINTUN_DLL_SRC}" ]]; then
printf '✗ wintun zip 中未找到 %s arch 的 wintun.dll\n' "${TARGET_ARCH}" >&2
exit 1
fi
cp "${WINTUN_DLL_SRC}" "${WINTUN_OUT}"
printf '✓ wintun.dll: %s\n' "${WINTUN_OUT}"
fi
fi
# ── 最终输出摘要 ─────────────────────────────────────────────────────────────
printf '\n=== 产物清单: %s ===\n' "${DIST_DIR}"
ls -lh "${DIST_DIR}/"