diff --git a/.gitignore b/.gitignore index 78a4219..19a1316 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,31 @@ -# IDE -.idea/ -.vscode/ - -# Local env -.env - -# Runtime -runtime/ - -# Local dashboard unpacked files -resources/dashboard/dist/ - -# Keep packaged dashboard asset -!resources/dashboard/dist.zip - -# Local test scripts -test.sh - -# OpenClaw control-plane local scaffolding -ai/ -.openclaw-control-plane/ +# IDE +.idea/ +.vscode/ + +# Local env +.env + +# Runtime +runtime/ + +# Optional offline assets +resources/bin/mihomo/ +resources/bin/clash/ +resources/bin/yq/ +resources/bin/subconverter/ +resources/geo/ +resources/asset-manifest.env +resources/SHA256SUMS + +# Local dashboard unpacked files +resources/dashboard/dist/ + +# Keep packaged dashboard asset +!resources/dashboard/dist.zip + +# Local test scripts +test.sh + +# OpenClaw control-plane local scaffolding +ai/ +.openclaw-control-plane/ diff --git a/README.md b/README.md index f94fdb1..3d96806 100644 --- a/README.md +++ b/README.md @@ -312,13 +312,14 @@ MIXED_PORT=7890 EXTERNAL_CONTROLLER=0.0.0.0:9090 CLASH_CONTROLLER_SECRET=your-secret CLASH_SUBSCRIPTION_URL=https://example.com/sub -MIHOMO_VERSION=latest -CLASH_VERSION=latest -YQ_VERSION=v4.44.3 +MIHOMO_VERSION=v1.19.23 +CLASH_VERSION=v1.18.0 +YQ_VERSION=v4.52.4 SUBCONVERTER_VERSION=v0.9.9 MIHOMO_DOWNLOAD_BASE=https://github.com/MetaCubeX/mihomo/releases/download CLASH_DOWNLOAD_BASE=https://github.com/WindSpiritSR/clash/releases/download CLASH_BUNDLED_ASSET_ENABLED=true +CLASH_OFFLINE=false CLASH_SHELL_AUTO_RESTORE_PROXY=true CLASH_PREDOWNLOAD_GEO=true ``` @@ -349,9 +350,7 @@ CLASH_GH_PROXY=https://ghfast.top 当前正式支持的架构为 `amd64`、`arm64`、`armv7`。超出这三种架构时会明确失败,不会伪装成已支持。 -如果安装环境访问 GitHub 很慢,可以把 Mihomo、yq、subconverter 的刚需文件跟随项目一起分发。安装和 `clash upgrade` 会优先读取 `resources/bin` 中与当前版本、架构对应的精确文件名;本地没有对应文件时,会回退到原来的远程下载逻辑,不影响后续升级内核。 - -Clash 仅作为兼容内核处理,固定走远程下载,不会命中 `resources/bin` 中的本地资源。 +如果安装环境访问 GitHub 很慢,可以把 Mihomo/Clash、yq、subconverter 的刚需文件跟随项目一起分发。安装和 `clash upgrade` 会优先读取 `resources/bin` 中与当前版本、架构对应的精确文件名;普通在线模式下,本地没有对应文件时会回退到远程下载。 推荐路径直接放在分类目录下: @@ -359,6 +358,9 @@ Clash 仅作为兼容内核处理,固定走远程下载,不会命中 `resour resources/bin/mihomo/mihomo-linux-amd64-compatible-v1.19.23.gz resources/bin/mihomo/mihomo-linux-arm64-v1.19.23.gz resources/bin/mihomo/mihomo-linux-armv7-v1.19.23.gz +resources/bin/clash/clash-linux-amd64-v1.18.0.gz +resources/bin/clash/clash-linux-arm64-v1.18.0.gz +resources/bin/clash/clash-linux-armv7-v1.18.0.gz resources/bin/yq/yq_linux_amd64.tar.gz resources/bin/yq/yq_linux_arm64.tar.gz resources/bin/yq/yq_linux_arm.tar.gz @@ -372,6 +374,39 @@ resources/geo/Country.mmdb 也可以设置 `CLASH_BUNDLED_ASSET_ENABLED=false` 强制跳过内置文件,或用 `CLASH_BUNDLED_ASSET_DIR=/path/to/assets` 指向项目外的资源目录。Mihomo、yq、subconverter 兼容旧路径 `resources/bin///`。 +#### 离线资源包 + +推荐在有代理或网络较好的设备上,按目标主机架构生成资源包: + +```bash +# 查看将要下载的文件,不产生网络请求 +bash scripts/prefetch-assets.sh --arch amd64 --dry-run + +# 生成 amd64 + Mihomo 资源包 +bash scripts/prefetch-assets.sh \ + --arch amd64 \ + --kernel mihomo \ + --output clash-assets-mihomo-amd64.tar.gz +``` + +支持的目标架构为 `amd64`、`arm64`、`armv7`。脚本默认包含内核、yq、subconverter 和五个 GEO 文件,只打包 `resources/` 静态资源,不会把本机的订阅、日志、密钥或其他 `runtime/` 状态带入资源包。压缩包同时包含版本/架构清单与 `SHA256SUMS`。 + +把项目和资源包复制到目标主机,在项目根目录执行: + +```bash +tar -xzf clash-assets-mihomo-amd64.tar.gz +bash install.sh --offline +``` + +`--offline` 等价于本次安装设置 `CLASH_OFFLINE=true`。安装脚本会先集中检查架构、版本、校验和及所有必需文件;有任何缺失都会明确列出并停止,不会静默回退到 GitHub。也可以和安装范围一起使用: + +```bash +bash install.sh system --offline +bash install.sh user --offline +``` + +严格离线安装时,订阅也不能使用远程 URL。可暂时不配置订阅,或者提前准备 Clash YAML 并通过 `file:///绝对路径/config.yaml` 导入。使用 `--no-geo` 生成精简资源包时,目标项目还需要设置 `CLASH_PREDOWNLOAD_GEO=false`;若最终配置使用 `GEOIP`,仍须提供 `Country.mmdb`。 + ### `runtime/mixin.yaml`(兼容 `config/mixin.yaml`) 用于对最终运行配置做补丁: diff --git a/install.sh b/install.sh index 7eabe99..3702ac1 100755 --- a/install.sh +++ b/install.sh @@ -12,10 +12,20 @@ source "$PROJECT_DIR/scripts/init/systemd-user.sh" source "$PROJECT_DIR/scripts/init/script.sh" init_project_context "$PROJECT_DIR" -guard_unsafe_sudo_auto_install "${1:-}" +parse_install_options "$@" + +if [ "$INSTALL_SHOW_HELP" = "true" ]; then + print_install_usage + exit 0 +fi + +guard_unsafe_sudo_auto_install "$INSTALL_REQUESTED_SCOPE" load_env_if_exists +if [ "$INSTALL_OFFLINE_REQUESTED" = "true" ]; then + export CLASH_OFFLINE="true" +fi migrate_env_legacy_compat_fields "$PROJECT_DIR/.env" -detect_install_scope "${1:-auto}" +detect_install_scope "$INSTALL_REQUESTED_SCOPE" ensure_project_not_wsl_windows_mount ensure_openwrt_install_supported @@ -23,6 +33,7 @@ ensure_required_commands init_layout ensure_dashboard_deploy_prerequisites +preflight_offline_assets resolve_geo_assets diff --git a/resources/bin/README.md b/resources/bin/README.md index 6199630..a55d53c 100644 --- a/resources/bin/README.md +++ b/resources/bin/README.md @@ -1,36 +1,46 @@ -# 内置运行依赖 - -如果安装时 GitHub 下载太慢,可以把 Mihomo、yq、subconverter 提前放到本目录。安装和 `clash upgrade` 会优先读取这里的文件;没有匹配文件时,仍会回退到原来的下载逻辑。 - -Clash 是兼容内核,固定走远程下载,不使用本目录中的本地资源。 - -默认目录结构: - -```text -resources/bin/ - mihomo/ - mihomo-linux-amd64-compatible-v1.19.23.gz - mihomo-linux-arm64-v1.19.23.gz - mihomo-linux-armv7-v1.19.23.gz - yq/ - yq_linux_amd64.tar.gz - yq_linux_arm64.tar.gz - yq_linux_arm.tar.gz - subconverter/ - subconverter_linux64.tar.gz - subconverter_aarch64.tar.gz - subconverter_armv7.tar.gz -``` - -当前正式支持 `amd64`、`arm64`、`armv7`。版本号要和 `.env` 里的 `MIHOMO_VERSION`、`YQ_VERSION`、`SUBCONVERTER_VERSION` 对应。脚本只按当前目标文件名精确命中,不会扫描目录,也不会自动选择最高版本;如果本地没有对应文件,会继续联网下载。 - -Mihomo、yq、subconverter 兼容旧路径 `resources/bin///`,但默认推荐直接使用 `resources/bin//`。 - -可选开关: - -```bash -export CLASH_BUNDLED_ASSET_ENABLED=false -export CLASH_BUNDLED_ASSET_DIR=/path/to/assets -``` - -`Country.mmdb` 仍放在 `resources/geo/Country.mmdb` 或 `resources/geo/country.mmdb`。 +# 内置运行依赖 + +如果安装时 GitHub 下载太慢,可以把 Mihomo/Clash、yq、subconverter 提前放到本目录。安装和 `clash upgrade` 会优先读取这里的文件;普通在线模式下,没有匹配文件时仍会回退到原来的下载逻辑。 + +默认目录结构: + +```text +resources/bin/ + mihomo/ + mihomo-linux-amd64-compatible-v1.19.23.gz + mihomo-linux-arm64-v1.19.23.gz + mihomo-linux-armv7-v1.19.23.gz + clash/ + clash-linux-amd64-v1.18.0.gz + clash-linux-arm64-v1.18.0.gz + clash-linux-armv7-v1.18.0.gz + yq/ + yq_linux_amd64.tar.gz + yq_linux_arm64.tar.gz + yq_linux_arm.tar.gz + subconverter/ + subconverter_linux64.tar.gz + subconverter_aarch64.tar.gz + subconverter_armv7.tar.gz +``` + +当前正式支持 `amd64`、`arm64`、`armv7`。版本号要和 `.env` 里的 `MIHOMO_VERSION`、`YQ_VERSION`、`SUBCONVERTER_VERSION` 对应。脚本只按当前目标文件名精确命中,不会扫描目录,也不会自动选择最高版本;如果本地没有对应文件,会继续联网下载。 + +Mihomo、yq、subconverter 兼容旧路径 `resources/bin///`,但默认推荐直接使用 `resources/bin//`。 + +可选开关: + +```bash +export CLASH_BUNDLED_ASSET_ENABLED=false +export CLASH_BUNDLED_ASSET_DIR=/path/to/assets +``` + +`Country.mmdb` 仍放在 `resources/geo/Country.mmdb` 或 `resources/geo/country.mmdb`。 + +推荐通过项目提供的脚本生成完整资源包: + +```bash +bash scripts/prefetch-assets.sh --arch amd64 --kernel mihomo +``` + +将生成的压缩包复制到目标主机并解压到项目根目录后,可执行 `bash install.sh --offline`。严格离线模式会在安装前检查全部资源,缺失时直接失败,不会回退联网下载。资源包只包含 `resources/`,不会包含可能带有订阅、日志或密钥的 `runtime/`。 diff --git a/scripts/core/common.sh b/scripts/core/common.sh index 55543a7..395b3bc 100644 --- a/scripts/core/common.sh +++ b/scripts/core/common.sh @@ -428,16 +428,75 @@ bundled_asset_root() { echo "$RESOURCE_DIR/bin" } -copy_bundled_asset() { +bundled_asset_metadata_root() { + if [ -n "${CLASH_BUNDLED_ASSET_DIR:-}" ]; then + echo "$CLASH_BUNDLED_ASSET_DIR" + return 0 + fi + + [ -n "${RESOURCE_DIR:-}" ] || return 1 + echo "$RESOURCE_DIR" +} + +bundled_asset_manifest_file() { + local root + + root="$(bundled_asset_metadata_root 2>/dev/null || true)" + [ -n "${root:-}" ] || return 1 + echo "$root/asset-manifest.env" +} + +bundled_asset_checksums_file() { + local root + + root="$(bundled_asset_metadata_root 2>/dev/null || true)" + [ -n "${root:-}" ] || return 1 + echo "$root/SHA256SUMS" +} + +read_bundled_asset_manifest_value() { + local key="$1" + local file + + file="$(bundled_asset_manifest_file 2>/dev/null || true)" + [ -n "${file:-}" ] && [ -f "$file" ] || return 1 + + sed -nE "s/^[[:space:]]*${key}=['\"]?([^'\"]*)['\"]?$/\1/p" "$file" | head -n 1 +} + +bundled_asset_manifest_compatible() { + local category="$1" + local version="$2" + local file bundle_arch bundle_version version_key + + file="$(bundled_asset_manifest_file 2>/dev/null || true)" + [ -n "${file:-}" ] && [ -f "$file" ] || return 0 + + bundle_arch="$(read_bundled_asset_manifest_value "BUNDLE_ARCH" 2>/dev/null || true)" + if [ -n "${bundle_arch:-}" ] && [ "$bundle_arch" != "$(get_arch)" ]; then + return 1 + fi + + case "$category" in + mihomo) version_key="MIHOMO_VERSION" ;; + clash) version_key="CLASH_VERSION" ;; + yq) version_key="YQ_VERSION" ;; + subconverter) version_key="SUBCONVERTER_VERSION" ;; + *) return 0 ;; + esac + + bundle_version="$(read_bundled_asset_manifest_value "$version_key" 2>/dev/null || true)" + [ -z "${bundle_version:-}" ] || [ "$bundle_version" = "$version" ] +} + +find_bundled_asset() { local category="$1" local version="$2" local file="$3" - local out="$4" - local asset_name="${5:-$file}" local root candidate - [ "$category" != "clash" ] || return 1 bundled_asset_enabled || return 1 + bundled_asset_manifest_compatible "$category" "$version" || return 1 root="$(bundled_asset_root 2>/dev/null || true)" [ -n "${root:-}" ] || return 1 @@ -448,18 +507,48 @@ copy_bundled_asset() { "$root/$file" \ "$RESOURCE_DIR/$category/$file"; do [ -s "$candidate" ] || continue - - mkdir -p "$(dirname "$out")" - if [ "$candidate" != "$out" ]; then - cp -f "$candidate" "$out" - fi - success "${asset_name} 已使用内置资源:$candidate" + echo "$candidate" return 0 done return 1 } +copy_bundled_asset() { + local category="$1" + local version="$2" + local file="$3" + local out="$4" + local asset_name="${5:-$file}" + local candidate + + candidate="$(find_bundled_asset "$category" "$version" "$file" 2>/dev/null || true)" + [ -n "${candidate:-}" ] || return 1 + + mkdir -p "$(dirname "$out")" + if [ "$candidate" != "$out" ]; then + cp -f "$candidate" "$out" + fi + success "${asset_name} 已使用内置资源:$candidate" +} + +offline_mode_enabled() { + case "${CLASH_OFFLINE:-false}" in + true|1|yes|on|TRUE|YES|ON) return 0 ;; + *) return 1 ;; + esac +} + +offline_download_blocked() { + local asset_name="$1" + local url="${2:-}" + local url_hint="" + + [ -n "${url:-}" ] && url_hint=";远程地址:$url" + die_state "离线模式禁止下载:${asset_name}${url_hint}" \ + "请补齐离线资源包后重试,或移除 --offline / CLASH_OFFLINE=true 允许联网下载" +} + download_connect_timeout() { echo "${CLASH_DOWNLOAD_CONNECT_TIMEOUT:-8}" } @@ -934,6 +1023,10 @@ download_file() { local tried_urls="" local fetch_tmp + if offline_mode_enabled; then + offline_download_blocked "$asset_name" "$url" + fi + mkdir -p "$(dirname "$out")" rm -f "$out" 2>/dev/null || true @@ -1031,6 +1124,10 @@ download_http_fetch_to_file() { local max_time="${5:-300}" local progress_arg="--progress-bar" + if offline_mode_enabled; then + offline_download_blocked "$(basename "${url%%\?*}")" "$url" + fi + curl_download \ "$progress_arg" \ --show-error \ @@ -1171,6 +1268,46 @@ guard_unsafe_sudo_auto_install() { fi } +parse_install_options() { + local arg + + INSTALL_REQUESTED_SCOPE="auto" + INSTALL_OFFLINE_REQUESTED="false" + INSTALL_SHOW_HELP="false" + + for arg in "$@"; do + case "$arg" in + system|user|auto) + if [ "$INSTALL_REQUESTED_SCOPE" != "auto" ] && [ "$INSTALL_REQUESTED_SCOPE" != "$arg" ]; then + die_usage "安装范围不能同时指定为 $INSTALL_REQUESTED_SCOPE 和 $arg" \ + "用法:bash install.sh [system|user|auto] [--offline]" + fi + INSTALL_REQUESTED_SCOPE="$arg" + ;; + --offline) + INSTALL_OFFLINE_REQUESTED="true" + ;; + -h|--help) + INSTALL_SHOW_HELP="true" + ;; + *) + die_usage "未知安装参数:$arg" \ + "用法:bash install.sh [system|user|auto] [--offline]" + ;; + esac + done +} + +print_install_usage() { + cat <<'EOF' +用法: + bash install.sh [system|user|auto] [--offline] + +选项: + --offline 严格离线安装;缺少本地资源时直接失败,不回退联网下载 +EOF +} + detect_install_scope() { local requested="${1:-auto}" diff --git a/scripts/core/runtime.sh b/scripts/core/runtime.sh index cefa8c8..0323ca5 100644 --- a/scripts/core/runtime.sh +++ b/scripts/core/runtime.sh @@ -13,10 +13,256 @@ GEO_ASSET_DOWNLOADS=( "resources/geo/GeoSite.dat https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat" ) -resolve_geo_assets() { +geo_predownload_enabled() { case "${CLASH_PREDOWNLOAD_GEO:-true}" in - 0|false|no|off|disable|disabled|FALSE|NO|OFF) return 0 ;; + 0|false|no|off|disable|disabled|FALSE|NO|OFF) return 1 ;; + *) return 0 ;; esac +} + +yq_asset_file() { + case "$1" in + amd64) echo "yq_linux_amd64.tar.gz" ;; + arm64) echo "yq_linux_arm64.tar.gz" ;; + armv7) echo "yq_linux_arm.tar.gz" ;; + *) die "暂不支持的 yq 架构:$1" ;; + esac +} + +mihomo_asset_file() { + local arch="$1" + local version="$2" + + case "$arch" in + amd64) echo "mihomo-linux-amd64-compatible-${version}.gz" ;; + arm64) echo "mihomo-linux-arm64-${version}.gz" ;; + armv7) echo "mihomo-linux-armv7-${version}.gz" ;; + *) die "暂不支持的 Mihomo 架构:$arch" ;; + esac +} + +subconverter_asset_file() { + case "$1" in + amd64) echo "subconverter_linux64.tar.gz" ;; + arm64) echo "subconverter_aarch64.tar.gz" ;; + armv7) echo "subconverter_armv7.tar.gz" ;; + *) die "暂不支持的 subconverter 架构:$1" ;; + esac +} + +clash_asset_candidates() { + local arch="$1" + local version="$2" + local version_no_v="${version#v}" + + case "$arch" in + amd64) + printf '%s\n' \ + "clash-linux-amd64-${version}.gz" \ + "clash-linux-amd64-v${version_no_v}.gz" + ;; + arm64) + printf '%s\n' \ + "clash-linux-arm64-${version}.gz" \ + "clash-linux-arm64-v${version_no_v}.gz" \ + "clash-linux-armv8-${version}.gz" \ + "clash-linux-armv8-v${version_no_v}.gz" + ;; + armv7) + printf '%s\n' \ + "clash-linux-armv7-${version}.gz" \ + "clash-linux-armv7-v${version_no_v}.gz" + ;; + *) + die "暂不支持的 Clash 架构:$arch" + ;; + esac +} + +verify_offline_asset_bundle_metadata() { + local manifest checksums metadata_root actual expected kernel + local failed="false" + + manifest="$(bundled_asset_manifest_file 2>/dev/null || true)" + checksums="$(bundled_asset_checksums_file 2>/dev/null || true)" + metadata_root="$(bundled_asset_metadata_root 2>/dev/null || true)" + + if [ -n "${manifest:-}" ] && [ -f "$manifest" ]; then + actual="$(read_bundled_asset_manifest_value "BUNDLE_ARCH" 2>/dev/null || true)" + expected="$(get_arch)" + if [ -n "${actual:-}" ] && [ "$actual" != "$expected" ]; then + error "离线资源包架构不匹配:资源包=$actual,目标=$expected" + failed="true" + fi + + actual="$(read_bundled_asset_manifest_value "YQ_VERSION" 2>/dev/null || true)" + expected="${YQ_VERSION:-$DEFAULT_YQ_VERSION}" + if [ -n "${actual:-}" ] && [ "$actual" != "$expected" ]; then + error "离线资源包 yq 版本不匹配:资源包=$actual,目标=$expected" + failed="true" + fi + + actual="$(read_bundled_asset_manifest_value "SUBCONVERTER_VERSION" 2>/dev/null || true)" + expected="${SUBCONVERTER_VERSION:-$DEFAULT_SUBCONVERTER_VERSION}" + if [ -n "${actual:-}" ] && [ "$actual" != "$expected" ]; then + error "离线资源包 subconverter 版本不匹配:资源包=$actual,目标=$expected" + failed="true" + fi + + kernel="$(runtime_kernel_type)" + case "$kernel" in + mihomo) + actual="$(read_bundled_asset_manifest_value "MIHOMO_VERSION" 2>/dev/null || true)" + expected="${MIHOMO_VERSION:-$DEFAULT_MIHOMO_VERSION}" + ;; + clash) + actual="$(read_bundled_asset_manifest_value "CLASH_VERSION" 2>/dev/null || true)" + expected="${CLASH_VERSION:-$DEFAULT_CLASH_VERSION}" + ;; + *) + actual="" + expected="" + ;; + esac + + if [ -n "${actual:-}" ] && [ "$actual" != "$expected" ]; then + error "离线资源包 $kernel 版本不匹配:资源包=$actual,目标=$expected" + failed="true" + fi + fi + + if [ -n "${checksums:-}" ] && [ -f "$checksums" ]; then + if command -v sha256sum >/dev/null 2>&1; then + if ! (cd "$metadata_root" && sha256sum -c "$(basename "$checksums")" >/dev/null); then + error "离线资源包 SHA256 校验失败:$checksums" + failed="true" + fi + elif command -v shasum >/dev/null 2>&1; then + if ! (cd "$metadata_root" && shasum -a 256 -c "$(basename "$checksums")" >/dev/null); then + error "离线资源包 SHA256 校验失败:$checksums" + failed="true" + fi + else + warn "系统缺少 sha256sum/shasum,已跳过离线资源包完整性校验" + fi + fi + + [ "$failed" = "false" ] +} + +offline_asset_missing_text() { + local category="$1" + local version="$2" + local file="$3" + local label="$4" + + if [ -n "${CLASH_BUNDLED_ASSET_DIR:-}" ]; then + printf '%s:%s/%s/%s(版本 %s)' \ + "$label" "${CLASH_BUNDLED_ASSET_DIR%/}" "$category" "$file" "$version" + return 0 + fi + + case "$category" in + geo) + printf '%s:resources/geo/%s' "$label" "$file" + ;; + *) + printf '%s:resources/bin/%s/%s(版本 %s)' "$label" "$category" "$file" "$version" + ;; + esac +} + +preflight_offline_assets() { + offline_mode_enabled || return 0 + + local arch kernel version file item path + local missing=() + local candidate_found="false" + + arch="$(get_arch)" + kernel="$(runtime_kernel_type)" + + ui_section "离线资源预检" + ui_kv "🧩" "目标架构" "$arch" + ui_kv "🚀" "目标内核" "$kernel" + + if ! verify_offline_asset_bundle_metadata; then + die_state "离线资源包元数据或完整性校验失败" \ + "请重新生成与当前架构、版本匹配的资源包" + fi + + case "$kernel" in + mihomo) + version="${MIHOMO_VERSION:-$DEFAULT_MIHOMO_VERSION}" + file="$(mihomo_asset_file "$arch" "$version")" + if [ ! -x "$(mihomo_bin)" ] \ + && ! find_bundled_asset "mihomo" "$version" "$file" >/dev/null 2>&1; then + missing+=("$(offline_asset_missing_text "mihomo" "$version" "$file" "Mihomo")") + fi + ;; + clash) + version="${CLASH_VERSION:-$DEFAULT_CLASH_VERSION}" + if [ ! -x "$(clash_bin)" ]; then + candidate_found="false" + while IFS= read -r file; do + [ -n "${file:-}" ] || continue + if find_bundled_asset "clash" "$version" "$file" >/dev/null 2>&1; then + candidate_found="true" + break + fi + done </dev/null 2>&1; then + missing+=("$(offline_asset_missing_text "yq" "$version" "$file" "yq")") + fi + + version="${SUBCONVERTER_VERSION:-$DEFAULT_SUBCONVERTER_VERSION}" + file="$(subconverter_asset_file "$arch")" + if ! { [ -x "$(subconverter_bin)" ] \ + && [ "$(subconverter_version_file_value)" = "$version" ] \ + && subconverter_runtime_layout_ready "$(subconverter_home)"; } \ + && ! find_bundled_asset "subconverter" "$version" "$file" >/dev/null 2>&1; then + missing+=("$(offline_asset_missing_text "subconverter" "$version" "$file" "subconverter")") + fi + + if geo_predownload_enabled; then + for item in "${GEO_ASSET_DOWNLOADS[@]}"; do + path="${item%% *}" + file="$(basename "$path")" + if [ ! -s "$RUNTIME_DIR/$file" ] \ + && ! find_bundled_asset "geo" "latest" "$file" >/dev/null 2>&1; then + missing+=("$(offline_asset_missing_text "geo" "latest" "$file" "$file")") + fi + done + fi + + if [ "${#missing[@]}" -gt 0 ]; then + error "离线安装缺少以下资源:" + printf ' - %s\n' "${missing[@]}" >&2 + die_state "离线资源预检失败,共缺少 ${#missing[@]} 项" \ + "在联网设备执行 scripts/prefetch-assets.sh --arch $arch,再把资源包复制到目标主机并解压到项目根目录" + fi + + success "离线资源预检通过" +} + +resolve_geo_assets() { + geo_predownload_enabled || return 0 [ -n "${PROJECT_DIR:-}" ] || return 0 @@ -44,12 +290,7 @@ resolve_yq() { return 0 fi - case "$arch" in - amd64) file="yq_linux_amd64.tar.gz" ;; - arm64) file="yq_linux_arm64.tar.gz" ;; - armv7) file="yq_linux_arm.tar.gz" ;; - *) die "暂不支持的 yq 架构:$arch" ;; - esac + file="$(yq_asset_file "$arch")" url="https://github.com/mikefarah/yq/releases/download/${version}/${file}" tmp_dir="$(mktemp -d)" @@ -86,12 +327,7 @@ resolve_mihomo() { return 0 fi - case "$arch" in - amd64) file="mihomo-linux-amd64-compatible-${version}.gz" ;; - arm64) file="mihomo-linux-arm64-${version}.gz" ;; - armv7) file="mihomo-linux-armv7-${version}.gz" ;; - *) die "暂不支持的 Mihomo 架构:$arch" ;; - esac + file="$(mihomo_asset_file "$arch" "$version")" if [ -n "${custom_url:-}" ]; then url="$custom_url" @@ -109,12 +345,11 @@ resolve_mihomo() { } resolve_clash() { - local arch version version_no_v url_base tmp_file url downloaded="false" + local arch version url_base tmp_file url downloaded="false" local candidates file arch="$(get_arch)" version="${CLASH_VERSION:-$DEFAULT_CLASH_VERSION}" - version_no_v="${version#v}" url_base="${CLASH_DOWNLOAD_BASE:-https://github.com/WindSpiritSR/clash/releases/download}" if [ -x "$(clash_bin)" ] \ @@ -123,36 +358,20 @@ resolve_clash() { return 0 fi - case "$arch" in - amd64) - candidates=" -clash-linux-amd64-${version}.gz -clash-linux-amd64-v${version_no_v}.gz -" - ;; - arm64) - candidates=" -clash-linux-arm64-${version}.gz -clash-linux-arm64-v${version_no_v}.gz -clash-linux-armv8-${version}.gz -clash-linux-armv8-v${version_no_v}.gz -" - ;; - armv7) - candidates=" -clash-linux-armv7-${version}.gz -clash-linux-armv7-v${version_no_v}.gz -" - ;; - *) - die "暂不支持的 Clash 架构:$arch" - ;; - esac + candidates="$(clash_asset_candidates "$arch" "$version")" tmp_file="$(mktemp)" rm -f "$tmp_file" for file in $candidates; do + if copy_bundled_asset "clash" "$version" "$file" "$tmp_file" "clash"; then + downloaded="true" + break + fi + done + + for file in $candidates; do + [ "$downloaded" = "false" ] || break url="${url_base}/${version}/${file}" if download_file "$url" "$tmp_file" "clash"; then @@ -273,12 +492,7 @@ resolve_subconverter() { fi fi - case "$arch" in - amd64) file="subconverter_linux64.tar.gz" ;; - arm64) file="subconverter_aarch64.tar.gz" ;; - armv7) file="subconverter_armv7.tar.gz" ;; - *) die "暂不支持的 subconverter 架构:$arch" ;; - esac + file="$(subconverter_asset_file "$arch")" url="https://github.com/asdlokj1qpi233/subconverter/releases/download/${version}/${file}" tmp_dir="$(mktemp -d)" diff --git a/scripts/dev/check-offline-assets.sh b/scripts/dev/check-offline-assets.sh new file mode 100644 index 0000000..7b8c35a --- /dev/null +++ b/scripts/dev/check-offline-assets.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=scripts/core/runtime.sh +source "$PROJECT_DIR/scripts/core/runtime.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +fixture_project="$tmp_dir/project" +RUNTIME_DIR="$fixture_project/runtime" +RESOURCE_DIR="$fixture_project/resources" +BIN_DIR="$RUNTIME_DIR/bin" +LOG_DIR="$RUNTIME_DIR/logs" + +mkdir -p \ + "$RESOURCE_DIR/bin/mihomo" \ + "$RESOURCE_DIR/bin/yq" \ + "$RESOURCE_DIR/bin/subconverter" \ + "$RESOURCE_DIR/geo" \ + "$BIN_DIR" \ + "$LOG_DIR" + +export CLASH_TEST_UNAME_M="amd64" +export KERNEL_TYPE="mihomo" +export CLASH_OFFLINE="true" +export CLASH_PREDOWNLOAD_GEO="true" +unset CLASH_BUNDLED_ASSET_DIR +unset CLASH_BUNDLED_ASSET_ENABLED + +mihomo_version="${MIHOMO_VERSION:-$DEFAULT_MIHOMO_VERSION}" +yq_version="${YQ_VERSION:-$DEFAULT_YQ_VERSION}" +subconverter_version="${SUBCONVERTER_VERSION:-$DEFAULT_SUBCONVERTER_VERSION}" + +mihomo_file="$(mihomo_asset_file amd64 "$mihomo_version")" +yq_file="$(yq_asset_file amd64)" +subconverter_file="$(subconverter_asset_file amd64)" + +printf 'mock\n' > "$RESOURCE_DIR/bin/mihomo/$mihomo_file" +printf 'mock\n' > "$RESOURCE_DIR/bin/yq/$yq_file" +printf 'mock\n' > "$RESOURCE_DIR/bin/subconverter/$subconverter_file" + +for item in "${GEO_ASSET_DOWNLOADS[@]}"; do + geo_path="${item%% *}" + printf 'mock\n' > "$RESOURCE_DIR/geo/$(basename "$geo_path")" +done + +preflight_offline_assets >/dev/null +echo "ok - complete offline asset set passes preflight" + +rm -f "$RESOURCE_DIR/bin/mihomo/$mihomo_file" +missing_output="$tmp_dir/missing-output" +if (preflight_offline_assets) >"$missing_output" 2>&1; then + echo "not ok - missing Mihomo asset unexpectedly passed preflight" >&2 + exit 1 +fi + +if ! grep -Fq "Mihomo" "$missing_output"; then + echo "not ok - missing asset output did not identify Mihomo" >&2 + cat "$missing_output" >&2 + exit 1 +fi +echo "ok - missing offline asset fails with an actionable name" + +blocked_output="$tmp_dir/blocked-output" +if (download_file "https://example.com/file" "$tmp_dir/downloaded" "fixture") >"$blocked_output" 2>&1; then + echo "not ok - offline download unexpectedly succeeded" >&2 + exit 1 +fi + +if [ -e "$tmp_dir/downloaded" ]; then + echo "not ok - offline download created an output file" >&2 + exit 1 +fi + +if ! grep -Fq "离线模式禁止下载" "$blocked_output"; then + echo "not ok - offline download failure was not explicit" >&2 + cat "$blocked_output" >&2 + exit 1 +fi +echo "ok - offline mode blocks remote fallback before curl" + +parse_install_options user --offline +if [ "$INSTALL_REQUESTED_SCOPE" != "user" ] \ + || [ "$INSTALL_OFFLINE_REQUESTED" != "true" ]; then + echo "not ok - install option parser lost scope or offline flag" >&2 + exit 1 +fi +echo "ok - install options accept scope and --offline together" + +dry_run_output="$tmp_dir/dry-run-output" +bash "$PROJECT_DIR/scripts/prefetch-assets.sh" \ + --arch arm64 \ + --kernel mihomo \ + --dry-run >"$dry_run_output" + +if ! grep -Fq "mihomo-linux-arm64-" "$dry_run_output" \ + || ! grep -Fq "subconverter_aarch64.tar.gz" "$dry_run_output" \ + || ! grep -Fq "未生成资源包" "$dry_run_output"; then + echo "not ok - prefetch dry run did not describe the arm64 bundle" >&2 + cat "$dry_run_output" >&2 + exit 1 +fi +echo "ok - prefetch dry run resolves target architecture without downloading" diff --git a/scripts/prefetch-assets.sh b/scripts/prefetch-assets.sh new file mode 100644 index 0000000..bd673e5 --- /dev/null +++ b/scripts/prefetch-assets.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# shellcheck source=scripts/core/runtime.sh +source "$PROJECT_DIR/scripts/core/runtime.sh" + +prefetch_usage() { + cat <<'EOF' +用法: + bash scripts/prefetch-assets.sh [选项] + +选项: + --arch amd64|arm64|armv7 目标主机架构;默认使用当前主机架构 + --kernel mihomo|clash 要打包的代理内核;默认读取 .env 或使用 mihomo + --output FILE 输出文件;默认 clash-assets--.tar.gz + --no-geo 不包含安装期 GEO 资源 + --dry-run 只显示下载计划,不下载或生成压缩包 + -h, --help 显示帮助 + +资源包复制到目标主机后,在项目根目录执行: + tar -xzf clash-assets--.tar.gz + bash install.sh --offline +EOF +} + +normalize_prefetch_arch() { + case "$1" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7l|armv7) echo "armv7" ;; + *) die_usage "不支持的目标架构:$1" "可选值:amd64、arm64、armv7" ;; + esac +} + +prefetch_sha256_line() { + local file="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" + return 0 + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" + return 0 + fi + + die "生成资源包需要 sha256sum 或 shasum" +} + +prefetch_download() { + local url="$1" + local out="$2" + local label="$3" + + mkdir -p "$(dirname "$out")" + download_file "$url" "$out" "$label" +} + +target_arch="" +target_kernel="" +output_file="" +include_geo="true" +dry_run="false" + +while [ "$#" -gt 0 ]; do + case "$1" in + --arch) + [ "$#" -ge 2 ] || die_usage "--arch 缺少参数" "可选值:amd64、arm64、armv7" + target_arch="$2" + shift 2 + ;; + --kernel) + [ "$#" -ge 2 ] || die_usage "--kernel 缺少参数" "可选值:mihomo、clash" + target_kernel="$2" + shift 2 + ;; + --output) + [ "$#" -ge 2 ] || die_usage "--output 缺少文件路径" "例如:--output /tmp/clash-assets-amd64.tar.gz" + output_file="$2" + shift 2 + ;; + --no-geo) + include_geo="false" + shift + ;; + --dry-run) + dry_run="true" + shift + ;; + -h|--help) + prefetch_usage + exit 0 + ;; + *) + die_usage "未知参数:$1" "执行 bash scripts/prefetch-assets.sh --help 查看用法" + ;; + esac +done + +init_project_context "$PROJECT_DIR" +load_env_if_exists + +target_arch="$(normalize_prefetch_arch "${target_arch:-$(get_arch)}")" +target_kernel="$(normalize_kernel_type "${target_kernel:-${KERNEL_TYPE:-mihomo}}")" + +case "$target_kernel" in + mihomo|clash) ;; + *) die_usage "不支持的目标内核:$target_kernel" "可选值:mihomo、clash" ;; +esac + +export CLASH_TEST_UNAME_M="$target_arch" +export CLASH_OFFLINE="false" + +mihomo_version="${MIHOMO_VERSION:-$DEFAULT_MIHOMO_VERSION}" +clash_version="${CLASH_VERSION:-$DEFAULT_CLASH_VERSION}" +yq_version="${YQ_VERSION:-$DEFAULT_YQ_VERSION}" +subconverter_version="${SUBCONVERTER_VERSION:-$DEFAULT_SUBCONVERTER_VERSION}" + +yq_file="$(yq_asset_file "$target_arch")" +subconverter_file="$(subconverter_asset_file "$target_arch")" +yq_url="https://github.com/mikefarah/yq/releases/download/${yq_version}/${yq_file}" +subconverter_url="https://github.com/asdlokj1qpi233/subconverter/releases/download/${subconverter_version}/${subconverter_file}" + +case "$target_kernel" in + mihomo) + kernel_file="$(mihomo_asset_file "$target_arch" "$mihomo_version")" + if [ -n "${MIHOMO_DOWNLOAD_URL:-}" ]; then + kernel_url="$MIHOMO_DOWNLOAD_URL" + else + kernel_url="${MIHOMO_DOWNLOAD_BASE:-https://github.com/MetaCubeX/mihomo/releases/download}" + kernel_url="${kernel_url%/}/${mihomo_version}/${kernel_file}" + fi + ;; + clash) + kernel_file="$(clash_asset_candidates "$target_arch" "$clash_version" | head -n 1)" + kernel_url="${CLASH_DOWNLOAD_BASE:-https://github.com/WindSpiritSR/clash/releases/download}" + kernel_url="${kernel_url%/}/${clash_version}/${kernel_file}" + ;; +esac + +ui_section "资源包计划" +ui_kv "🧩" "目标架构" "$target_arch" +ui_kv "🚀" "目标内核" "$target_kernel" +printf ' - %s\n' "$kernel_url" +printf ' - %s\n' "$yq_url" +printf ' - %s\n' "$subconverter_url" + +if [ "$include_geo" = "true" ]; then + for item in "${GEO_ASSET_DOWNLOADS[@]}"; do + printf ' - %s\n' "${item#* }" + done +fi + +if [ "$dry_run" = "true" ]; then + success "仅显示下载计划,未生成资源包" + exit 0 +fi + +command -v curl >/dev/null 2>&1 || die "当前系统缺少 curl" +command -v tar >/dev/null 2>&1 || die "当前系统缺少 tar" + +stage_dir="$(mktemp -d)" +trap 'rm -rf "$stage_dir"' EXIT + +RESOURCE_DIR="$stage_dir/resources" +RUNTIME_DIR="$stage_dir/runtime" +BIN_DIR="$RUNTIME_DIR/bin" +LOG_DIR="$RUNTIME_DIR/logs" +mkdir -p "$RESOURCE_DIR/bin" "$RESOURCE_DIR/geo" "$RUNTIME_DIR" + +prefetch_download "$kernel_url" "$RESOURCE_DIR/bin/$target_kernel/$kernel_file" "$target_kernel" +prefetch_download "$yq_url" "$RESOURCE_DIR/bin/yq/$yq_file" "yq" +prefetch_download \ + "$subconverter_url" \ + "$RESOURCE_DIR/bin/subconverter/$subconverter_file" \ + "subconverter" + +if [ "$include_geo" = "true" ]; then + for item in "${GEO_ASSET_DOWNLOADS[@]}"; do + geo_path="${item%% *}" + geo_url="${item#* }" + geo_file="$(basename "$geo_path")" + prefetch_download "$geo_url" "$RESOURCE_DIR/geo/$geo_file" "$geo_file" + done +fi + +source_commit="$(git -C "$PROJECT_DIR" rev-parse --verify HEAD 2>/dev/null || echo unknown)" +cat > "$RESOURCE_DIR/asset-manifest.env" < "$RESOURCE_DIR/SHA256SUMS" + +if [ -z "${output_file:-}" ]; then + output_file="$PWD/clash-assets-${target_kernel}-${target_arch}.tar.gz" +else + output_dir="$(dirname "$output_file")" + output_name="$(basename "$output_file")" + mkdir -p "$output_dir" + output_file="$(cd "$output_dir" && pwd)/$output_name" +fi + +tar -czf "$output_file" -C "$stage_dir" resources + +success "资源包已生成:$output_file" +ui_next "复制到目标主机的项目目录,解压后执行:bash install.sh --offline"