diff --git a/install.sh b/install.sh index 3702ac1..fb34bb9 100755 --- a/install.sh +++ b/install.sh @@ -57,12 +57,24 @@ ensure_subscription_bootstrap_for_install "default" prompt_subscription_if_needed if [ -n "$(subscription_url 2>/dev/null || true)" ]; then - if generate_config; then + if CLASH_INSTALL_ALLOW_SUBSCRIPTION_SKIP=true generate_config; then if [ -n "${INSTALL_PENDING_SUBSCRIPTION_URL:-}" ]; then write_env_value "CLASH_SUBSCRIPTION_URL" "$INSTALL_PENDING_SUBSCRIPTION_URL" fi echo "✨ 订阅已生效" post_install_verify + elif subscription_download_failed_or_cancelled; then + if clash_command_available; then + write_runtime_value "INSTALL_VERIFY_COMMAND_READY" "true" + else + write_runtime_value "INSTALL_VERIFY_COMMAND_READY" "false" + die "clash 管理命令安装失败,命令不可用" + fi + + write_runtime_value "INSTALL_VERIFY_CONFIG_READY" "false" + write_runtime_value "INSTALL_VERIFY_RUNTIME_READY" "false" + write_runtime_value "INSTALL_VERIFY_CONTROLLER_READY" "false" + write_runtime_event_value "RUNTIME_LAST_INSTALL_READY" "false" else write_runtime_value "INSTALL_VERIFY_CONFIG_READY" "false" write_runtime_value "INSTALL_VERIFY_RUNTIME_READY" "false" diff --git a/scripts/core/config.sh b/scripts/core/config.sh index d9f9804..6251321 100644 --- a/scripts/core/config.sh +++ b/scripts/core/config.sh @@ -651,28 +651,72 @@ download_candidate_fetch() { "$url" } -subscription_fake_progress_bar() { +subscription_download_wait_status() { local pid="$1" - local width=72 - local percent=3 - local filled bar + local started_at elapsed - while kill -0 "$pid" 2>/dev/null; do - percent=$((percent + 5)) - [ "$percent" -gt 94 ] && percent=94 + started_at="${SECONDS:-0}" - filled=$((percent * width / 100)) - bar="$(printf '%*s' "$filled" '' | tr ' ' '#')" + if [ ! -t 2 ]; then + printf '⏳ 正在下载订阅…(Ctrl+C 可跳过)\n' >&2 + while kill -0 "$pid" 2>/dev/null; do + sleep 1 + done + return 0 + fi - printf '\r%-72s %3d.0%%' "$bar" "$percent" - sleep 0.2 + while kill -0 "$pid" 2>/dev/null; do + elapsed=$((${SECONDS:-0} - started_at)) + printf '\r\033[K⏳ 正在下载订阅… 已等待 %s 秒(Ctrl+C 可跳过)' "$elapsed" >&2 + sleep 1 done + + ui_progress_clear } -subscription_fake_progress_done() { +subscription_download_done() { printf '\r######################################################################## 100.0%%\n' } +subscription_download_failure_reason() { + case "$1" in + 5|6) echo "域名解析失败" ;; + 7) echo "无法连接订阅服务器" ;; + 22) echo "订阅服务器返回 HTTP 错误" ;; + 28) echo "连接超时" ;; + 35) echo "TLS 握手失败" ;; + 52) echo "订阅服务器未返回数据" ;; + 56) echo "连接被中断" ;; + 60) echo "证书验证失败" ;; + *) echo "网络请求失败" ;; + esac +} + +subscription_download_failed_or_cancelled() { + case "${SUBSCRIPTION_DOWNLOAD_LAST_STATUS:-}" in + failed|cancelled) return 0 ;; + *) return 1 ;; + esac +} + +print_subscription_download_failure() { + local reason="$1" + + if [ "${CLASH_INSTALL_ALLOW_SUBSCRIPTION_SKIP:-false}" = "true" ]; then + printf '⚠ 订阅下载失败:%s;已跳过,请稍后执行 clash sub update\n' "$reason" >&2 + else + printf '❌ 订阅下载失败:%s,请检查网络或稍后重试\n' "$reason" >&2 + fi +} + +print_subscription_download_cancelled() { + if [ "${CLASH_INSTALL_ALLOW_SUBSCRIPTION_SKIP:-false}" = "true" ]; then + printf '⚠ 已取消订阅下载;安装继续,请稍后执行 clash sub update\n' >&2 + else + printf '⚠ 已取消订阅下载\n' >&2 + fi +} + download_subscription_fetch_quiet() { local url="$1" local out="$2" @@ -694,32 +738,69 @@ download_subscription_fetch_quiet() { download_subscription_file() { local url="$1" local out="$2" - local fetch_tmp pid rc + local fetch_tmp error_file pid rc reason error_detail + local cancelled="false" + local previous_int_trap + + SUBSCRIPTION_DOWNLOAD_LAST_STATUS="" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY="" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL="" mkdir -p "$(dirname "$out")" rm -f "$out" 2>/dev/null || true fetch_tmp="$(mktemp)" + error_file="$(mktemp)" rm -f "$fetch_tmp" 2>/dev/null || true - ui_download "正在下载:subscription" - - download_subscription_fetch_quiet "$url" "$fetch_tmp" & + download_subscription_fetch_quiet "$url" "$fetch_tmp" 2>"$error_file" & pid="$!" - subscription_fake_progress_bar "$pid" + previous_int_trap="$(trap -p INT || true)" + trap 'cancelled="true"; kill "$pid" 2>/dev/null || true' INT - wait "$pid" - rc="$?" + subscription_download_wait_status "$pid" || true + + if wait "$pid"; then + rc="0" + else + rc="$?" + fi + + if [ -n "${previous_int_trap:-}" ]; then + eval "$previous_int_trap" + else + trap - INT + fi + + if [ "$cancelled" = "true" ]; then + rc="130" + fi if [ "$rc" -eq 0 ]; then - subscription_fake_progress_done + SUBSCRIPTION_DOWNLOAD_LAST_STATUS="success" + subscription_download_done mv -f "$fetch_tmp" "$out" + rm -f "$error_file" 2>/dev/null || true return 0 fi - printf '\n' - rm -f "$fetch_tmp" 2>/dev/null || true + error_detail="$(tr '\r\n' ' ' < "$error_file" | sed 's/[[:space:]][[:space:]]*/ /g; s/^[[:space:]]*//; s/[[:space:]]*$//' || true)" + rm -f "$fetch_tmp" "$error_file" 2>/dev/null || true + + if [ "$cancelled" = "true" ]; then + SUBSCRIPTION_DOWNLOAD_LAST_STATUS="cancelled" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY="订阅下载已取消" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL="用户取消了订阅下载" + print_subscription_download_cancelled + return 130 + fi + + reason="$(subscription_download_failure_reason "$rc")" + SUBSCRIPTION_DOWNLOAD_LAST_STATUS="failed" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY="订阅下载失败:$reason" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL="curl exit code: $rc${error_detail:+; $error_detail}" + print_subscription_download_failure "$reason" return "$rc" } @@ -1064,11 +1145,20 @@ fail_build_with_detail() { if [ "$stage" = "fetch-source" ] && [ -n "${SUBCONVERTER_LAST_ERROR_DETAIL:-}" ]; then record_detail="$SUBCONVERTER_LAST_ERROR_DETAIL" + elif [ "$stage" = "fetch-source" ] && [ -n "${SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL:-}" ]; then + record_detail="$SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL" fi record_build_error_detail "$stage" "$record_detail" record_build_failure "$mode" "$policy" "$active" "$selected" "$included" "$failed" mark_runtime_build_not_applied "$stage" + + # The downloader already emitted one concise cause. Return without printing a + # second multi-line build error so callers can decide whether to retry or skip. + if [ "$stage" = "fetch-source" ] && [ -n "${SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY:-}" ]; then + return 1 + fi + die "$detail" } @@ -3967,6 +4057,9 @@ fetch_subscription_source() { SUBCONVERTER_LAST_ERROR_DETAIL="" SUBCONVERTER_LAST_ERROR_SUMMARY="" SUBCONVERTER_LAST_ZERO_NODES="false" + SUBSCRIPTION_DOWNLOAD_LAST_STATUS="" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY="" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL="" if [ -z "${url:-}" ]; then mark_subscription_health_failure "$name" "订阅源地址为空" @@ -4044,7 +4137,7 @@ fetch_subscription_source() { fi fi else - reason="订阅下载失败" + reason="${SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY:-订阅下载失败}" fi ;; convert) @@ -4138,6 +4231,8 @@ generate_config() { if [ -n "${SUBCONVERTER_LAST_ERROR_SUMMARY:-}" ]; then write_compile_error "当前主订阅不可用:$active_source" append_compile_error "reason : $SUBCONVERTER_LAST_ERROR_SUMMARY" + elif [ -n "${SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY:-}" ]; then + write_compile_error "$SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY" else write_compile_error "当前主订阅不可用" append_compile_error "source : $active_source" @@ -4152,6 +4247,7 @@ generate_config() { "$included_csv" \ "$failed_csv" \ "当前主订阅不可用:$active_source" + return 1 fi included_csv="$active_source" diff --git a/scripts/dev/check-subscription-download-feedback.sh b/scripts/dev/check-subscription-download-feedback.sh new file mode 100644 index 0000000..78d386a --- /dev/null +++ b/scripts/dev/check-subscription-download-feedback.sh @@ -0,0 +1,177 @@ +#!/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/common.sh +source "$PROJECT_DIR/scripts/core/common.sh" +# shellcheck source=scripts/core/config.sh +source "$PROJECT_DIR/scripts/core/config.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +RUNTIME_DIR="$tmp_dir/runtime" +mkdir -p "$RUNTIME_DIR" + +success_output="$tmp_dir/success.out" +failure_output="$tmp_dir/failure.out" +cancel_output="$tmp_dir/cancel.out" + +download_subscription_fetch_quiet() { + local _url="$1" + local out="$2" + + sleep 5 + printf 'proxies: []\nproxy-groups: []\nrules: []\n' > "$out" +} + +download_subscription_file "https://example.invalid/sub" "$tmp_dir/success.yaml" \ + >"$success_output" 2>&1 + +if grep -q '94\.0%' "$success_output"; then + echo "not ok - subscription download must not show fake 94% progress" >&2 + exit 1 +fi + +if ! grep -q '100\.0%' "$success_output"; then + echo "not ok - successful subscription download should keep the 100% completion line" >&2 + exit 1 +fi + +echo "ok - successful subscription download has no fake progress" + +download_subscription_fetch_quiet() { + printf 'curl: (28) Operation timed out\n' >&2 + return 28 +} + +set +e +CLASH_INSTALL_ALLOW_SUBSCRIPTION_SKIP=true \ + download_subscription_file "https://example.invalid/sub" "$tmp_dir/failure.yaml" \ + >"$failure_output" 2>&1 +failure_rc="$?" +set -e + +if [ "$failure_rc" -ne 28 ]; then + echo "not ok - timeout should preserve curl exit code 28, got $failure_rc" >&2 + exit 1 +fi + +expected_failure='⚠ 订阅下载失败:连接超时;已跳过,请稍后执行 clash sub update' +if ! grep -Fxq "$expected_failure" "$failure_output"; then + echo "not ok - install-time timeout should show one concise reason" >&2 + sed 's/^/ /' "$failure_output" >&2 + exit 1 +fi + +if [ "$(grep -Ec '^(⚠|❌).*订阅下载' "$failure_output" || true)" -ne 1 ]; then + echo "not ok - timeout should show exactly one subscription failure line" >&2 + sed 's/^/ /' "$failure_output" >&2 + exit 1 +fi + +if grep -q 'curl:' "$failure_output"; then + echo "not ok - raw curl errors should stay out of normal output" >&2 + exit 1 +fi + +echo "ok - failed subscription download shows one concise reason" + +download_subscription_fetch_quiet() { + local _url="$1" + local out="$2" + + sleep 30 + printf 'unexpected completion\n' > "$out" +} + +subscription_download_wait_status() { + kill -INT "$BASHPID" +} + +set +e +CLASH_INSTALL_ALLOW_SUBSCRIPTION_SKIP=true \ + download_subscription_file "https://example.invalid/sub" "$tmp_dir/cancel.yaml" \ + >"$cancel_output" 2>&1 +cancel_rc="$?" +set -e + +if [ "$cancel_rc" -ne 130 ]; then + echo "not ok - Ctrl+C should return 130, got $cancel_rc" >&2 + exit 1 +fi + +expected_cancel='⚠ 已取消订阅下载;安装继续,请稍后执行 clash sub update' +if ! grep -Fxq "$expected_cancel" "$cancel_output"; then + echo "not ok - Ctrl+C should show one concise skip message" >&2 + sed 's/^/ /' "$cancel_output" >&2 + exit 1 +fi + +if [ -e "$tmp_dir/cancel.yaml" ]; then + echo "not ok - cancelled subscription download left an output file" >&2 + exit 1 +fi + +echo "ok - Ctrl+C cancels only the subscription download" + +generate_marker="$tmp_dir/generate.marker" +generate_output="$tmp_dir/generate.out" +harness_config_dir="$tmp_dir/config" + +( + set +e + + compile_error="" + ensure_active_subscription_usable() { return 0; } + clear_compile_error() { compile_error=""; } + active_subscription_name() { echo "default"; } + config_tmp_dir() { echo "$harness_config_dir"; } + subscription_exists() { return 0; } + subscription_enabled() { return 0; } + fetch_subscription_source() { + SUBSCRIPTION_DOWNLOAD_LAST_STATUS="failed" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_SUMMARY="订阅下载失败:连接超时" + SUBSCRIPTION_DOWNLOAD_LAST_ERROR_DETAIL="curl exit code: 28" + return 1 + } + read_compile_error() { + [ -n "$compile_error" ] || return 1 + printf '%s\n' "$compile_error" + } + write_compile_error() { compile_error="$1"; } + append_compile_error() { compile_error="${compile_error}${compile_error:+\n}$1"; } + record_build_error_detail() { :; } + record_build_failure() { :; } + mark_runtime_build_not_applied() { :; } + + generate_config + generate_rc="$?" + + if subscription_download_failed_or_cancelled; then + skippable="true" + else + skippable="false" + fi + + printf 'rc=%s status=%s skippable=%s\n' \ + "$generate_rc" \ + "${SUBSCRIPTION_DOWNLOAD_LAST_STATUS:-}" \ + "$skippable" > "$generate_marker" +) >"$generate_output" 2>&1 + +if [ "$(cat "$generate_marker" 2>/dev/null || true)" != "rc=1 status=failed skippable=true" ]; then + echo "not ok - install control flow should survive a subscription download failure" >&2 + sed 's/^/ /' "$generate_output" >&2 + exit 1 +fi + +if [ -s "$generate_output" ]; then + echo "not ok - build layer repeated the downloader failure message" >&2 + sed 's/^/ /' "$generate_output" >&2 + exit 1 +fi + +echo "ok - install control flow can skip a failed subscription"