#!/bin/bash INSTALLER_CDN="https://tools.88de.de" AUTO_CDN="https://auto.toolscdn.88de.de" CDN_LIST="$AUTO_CDN" cdn_name() { case "$1" in "$INSTALLER_CDN") printf '主站 tools.88de.de' ;; "$AUTO_CDN") printf '智能 CDN' ;; *) printf '%s' "$1" ;; esac } cdn_url_order() { local preferred="$1" url printf '%s\n' "$preferred" if printf 'a\nb\n' | sort -R >/dev/null 2>&1; then for url in $CDN_LIST; do [ "$url" = "$preferred" ] && continue printf '%s\n' "$url" done | sort -R else for url in $CDN_LIST; do [ "$url" = "$preferred" ] && continue printf '%s\n' "$url" done fi } if [ "$(id -u)" -ne 0 ]; then printf '%s\n' "请使用 root 用户执行安装" exit 1 fi extract_ext_package() { local gz_file="$1" dest="$2" members_text typed_listing local member member_key mode size type total_size=0 member_count=0 local path rel mode_hex mode_value i local -a members=() typed=() local -A seen=() [ -f "$gz_file" ] && [ ! -L "$gz_file" ] || return 1 [ -d "$dest" ] && [ ! -L "$dest" ] || return 1 members_text=$(LC_ALL=C tar tzf "$gz_file" 2>/dev/null) || return 1 [ -n "$members_text" ] || return 1 while IFS= read -r member || [ -n "$member" ]; do [ -n "$member" ] || return 1 case "$member" in *[[:cntrl:]]*|*\\*|/*|*/..|../*|*/../*|./*|*/./*|*//* ) return 1 ;; esac member_key="${member%/}" [ -n "$member_key" ] || return 1 case "$member_key" in j-tools|bundle_meta|ext|ext/*) ;; *) return 1 ;; esac [ -z "${seen[$member_key]+x}" ] || return 1 seen["$member_key"]=1 members+=("$member_key") member_count=$((member_count + 1)) [ "$member_count" -le 1024 ] || return 1 done <<< "$members_text" typed_listing=$(LC_ALL=C tar --numeric-owner -tvzf "$gz_file" 2>/dev/null) || return 1 while IFS=$'\t' read -r mode size; do typed+=("$mode"$'\t'"$size"); done < <(LC_ALL=C awk '{print $1 "\t" $3}' <<< "$typed_listing") [ "${#typed[@]}" -eq "$member_count" ] || return 1 for i in "${!members[@]}"; do IFS=$'\t' read -r mode size <<< "${typed[$i]}" [[ "$mode" =~ ^[-d][rwxstST-]{9}$ ]] || return 1 type="${mode:0:1}" case "$type" in -|d) ;; *) return 1 ;; esac case "${mode:3:1}${mode:6:1}" in *[sS]*) return 1 ;; esac case "${mode:5:1}${mode:8:1}" in *w*) return 1 ;; esac [[ "$size" =~ ^[0-9]+$ ]] || return 1 if [ "$type" = "-" ]; then [ "$size" -le 67108864 ] 2>/dev/null || return 1 total_size=$((total_size + size)) [ "$total_size" -le 268435456 ] 2>/dev/null || return 1 fi done tar --no-same-owner --no-same-permissions -xzf "$gz_file" -C "$dest" 2>/dev/null || return 1 if ! LC_ALL=C find "$dest" -mindepth 1 -print0 >/dev/null 2>&1; then return 1; fi while IFS= read -r -d '' path; do rel="${path#"$dest"/}" case "$rel" in j-tools|bundle_meta|ext|ext/*) ;; *) return 1 ;; esac [ -L "$path" ] && return 1 [ -f "$path" ] || [ -d "$path" ] || return 1 if [ -f "$path" ]; then [ "$(stat -c '%h' "$path" 2>/dev/null)" -le 1 ] || return 1 fi mode_hex=$(stat -c '%f' "$path" 2>/dev/null) || return 1 [[ "$mode_hex" =~ ^[[:xdigit:]]+$ ]] || return 1 mode_value=$((16#$mode_hex)) (( (mode_value & 06000) == 0 )) || return 1 (( (mode_value & 00022) == 0 )) || return 1 done < <(LC_ALL=C find "$dest" -mindepth 1 -print0 2>/dev/null) [ -d "$dest/ext" ] && [ ! -L "$dest/ext" ] || return 1 for path in "$dest/j-tools" "$dest/bundle_meta" "$dest/ext/.bundle_meta" "$dest/ext/web.sh"; do [ -f "$path" ] && [ ! -L "$path" ] && [ -s "$path" ] || return 1 done return 0 } SKIP_CACHE="" UPDATE_MODE="" TOOL_DIR="/opt/J-tools" FORCED_CDN_URL="" while [ $# -gt 0 ]; do case "$1" in -r) SKIP_CACHE=1 ;; --update) UPDATE_MODE=1 ;; --tool-dir) shift [ -n "${1:-}" ] && TOOL_DIR="$1" ;; --cdn-url) shift [ -n "${1:-}" ] && FORCED_CDN_URL="$1" ;; esac shift done installer_ensure_safe_cwd() { pwd >/dev/null 2>&1 && return 0 cd / 2>/dev/null && return 0 [ -n "$TOOL_DIR" ] && cd "$TOOL_DIR" 2>/dev/null && return 0 return 1 } installer_ensure_safe_cwd mkdirdi_status() { local label="$1" status="$2" color="$3" printf ' %-22s %b%s%b\n' "$label" "$color" "$status" "$RESET" } RESET=$'\033[0m' GREEN=$'\033[0;32m' RED=$'\033[0;31m' YELLOW=$'\033[0;33m' GRAY=$'\033[38;5;244m' START_TS=$(date +%s 2>/dev/null || echo "") WORKDIR=$(mktemp -d /tmp/jt_install.XXXXXX 2>/dev/null || true) if [ -z "$WORKDIR" ]; then WORKDIR="/tmp/jt_install.$$" [ -e "$WORKDIR" ] && { printf '%s\n' "临时目录已存在,安装中止" exit 1 } fi mkdir -p "$WORKDIR/ext/bundle/core" "$WORKDIR/ext/bundle/modules" "$WORKDIR/ext/bundle/self" || exit 1 # REL-03: 回滚失败时保留 WORKDIR 内的 rollback 恢复材料 trap 'if [ "$ROLLBACK_FAILED" != "1" ]; then rm -rf "$WORKDIR" 2>/dev/null || true; fi' EXIT fmt_elapsed() { local total="$1" h m s case "$total" in ''|*[!0-9]*) return 1 ;; esac [ "$total" -lt 0 ] && return 1 if [ "$total" -lt 60 ]; then printf '%ss' "$total" return 0 fi if [ "$total" -lt 3600 ]; then m=$((total / 60)) s=$((total % 60)) printf '%sm %ss' "$m" "$s" return 0 fi h=$((total / 3600)) m=$(((total % 3600) / 60)) s=$((total % 60)) printf '%sh %sm %ss' "$h" "$m" "$s" } finish_elapsed_text() { local end_ts elapsed text end_ts=$(date +%s 2>/dev/null || echo "") case "$START_TS$end_ts" in *[!0-9]*|'') return 1 ;; esac elapsed=$((end_ts - START_TS)) [ "$elapsed" -lt 0 ] && return 1 text=$(fmt_elapsed "$elapsed" 2>/dev/null || true) [ -n "$text" ] || return 1 printf '%s' "$text" } # ---- 首装使用智能资源 CDN;源失败时保留统一失败诊断 ---- SELECTED_CDN="$AUTO_CDN" LOCKED_CDN="" cdn_label="" if [ -n "$SKIP_CACHE" ]; then # -r: 强制从主站 tools.88de.de 拉资源(可清缓存) SELECTED_CDN="$INSTALLER_CDN" cdn_label="强制主站 tools.88de.de" elif [ -n "$FORCED_CDN_URL" ]; then SELECTED_CDN="$FORCED_CDN_URL" cdn_label="$(cdn_name "$SELECTED_CDN")" else cdn_label="$(cdn_name "$SELECTED_CDN")" fi printf '%s\n' "==========================================" if [ -n "$UPDATE_MODE" ]; then printf ' 极 Tools 更新程序' else printf ' 极 Tools 安装程序' fi [ -n "$cdn_label" ] && printf ' %s' "$cdn_label" printf '\n%s\n' "==========================================" # Description: 使用统一资源 CDN 下载并保留失败诊断 # $1: 远程路径 # $2: 本地输出路径 # $3: 超时秒数(默认 10) # Returns: 0 = 成功, 1 = 全部失败 append_multiline_var() { local var_name="$1" line="$2" current="" current="${!var_name}" if [ -n "$current" ]; then printf -v "$var_name" '%s\n%s' "$current" "$line" else printf -v "$var_name" '%s' "$line" fi } write_dl_status() { local file="$1" result="$2" curl_rc="$3" http_code="$4" reason="$5" [ -n "$file" ] || return 0 { printf 'result=%s\n' "$result" printf 'curl_rc=%s\n' "$curl_rc" printf 'http_code=%s\n' "$http_code" printf 'reason=%s\n' "$reason" } > "$file" 2>/dev/null || true } read_dl_status_field() { local file="$1" key="$2" value="" [ -f "$file" ] || return 1 value=$(sed -n "s/^$key=//p" "$file" 2>/dev/null | head -n 1) printf '%s' "$value" } classify_curl_failure() { local exit_code="$1" http_code="$2" case "$exit_code" in 3|5) printf 'URL/代理配置异常(curl %s)' "$exit_code" ;; 6) printf 'DNS/解析失败(curl 6)' ;; 7|52|55|56) printf '连接失败(curl %s)' "$exit_code" ;; 18) printf '下载内容不完整(curl 18)' ;; 23|26) printf '本地写入失败(curl %s)' "$exit_code" ;; 28) printf '连接超时(curl 28)' ;; 35|51|58|60) printf 'TLS/证书失败(curl %s)' "$exit_code" ;; 47) printf '重定向失败(curl 47)' ;; 22) case "$http_code" in 404) printf 'HTTP 404' ;; 5[0-9][0-9]) printf 'HTTP 5xx' ;; [1-9][0-9][0-9]) printf 'HTTP %s' "$http_code" ;; *) printf 'HTTP 错误(curl 22)' ;; esac ;; *) case "$http_code" in 404) printf 'HTTP 404' ;; 5[0-9][0-9]) printf 'HTTP 5xx' ;; [1-9][0-9][0-9]) printf 'HTTP %s' "$http_code" ;; *) printf 'curl %s' "$exit_code" ;; esac ;; esac } download_failure_reason_from_state() { local state_file="$1" curl_rc http_code reason curl_rc=$(read_dl_status_field "$state_file" "curl_rc" 2>/dev/null || true) http_code=$(read_dl_status_field "$state_file" "http_code" 2>/dev/null || true) reason=$(read_dl_status_field "$state_file" "reason" 2>/dev/null || true) if [ -n "$reason" ]; then printf '%s' "$reason" elif [ -n "$curl_rc" ]; then classify_curl_failure "$curl_rc" "$http_code" else printf '%s' "状态采集失败" fi } download_failure_category_from_state() { local state_file="$1" curl_rc reason curl_rc=$(read_dl_status_field "$state_file" "curl_rc" 2>/dev/null || true) reason=$(read_dl_status_field "$state_file" "reason" 2>/dev/null || true) case "$curl_rc" in local_mkdir|23|26) printf '%s' "local_write" ;; *) case "$reason" in "临时目录创建失败") printf '%s' "local_write" ;; *) printf '%s' "remote_fetch" ;; esac ;; esac } dl_capture_success_from_tmpd() { local tmpd="$1" output="$2" status_file="$3" local done_file idx state_file curl_rc http_code reason for done_file in "$tmpd"/*.done; do [ -e "$done_file" ] || continue idx=${done_file##*/} idx=${idx%.done} state_file="$tmpd/$idx.status" curl_rc=$(read_dl_status_field "$state_file" "curl_rc" 2>/dev/null || true) http_code=$(read_dl_status_field "$state_file" "http_code" 2>/dev/null || true) reason=$(read_dl_status_field "$state_file" "reason" 2>/dev/null || true) mv "$done_file" "$output" write_dl_status "$status_file" "ok" "${curl_rc:-0}" "$http_code" "$reason" return 0 done return 1 } dl_capture_failure_from_tmpd() { local tmpd="$1" status_file="$2" local state_file result curl_rc http_code reason for state_file in "$tmpd"/*.status; do [ -e "$state_file" ] || continue result=$(read_dl_status_field "$state_file" "result" 2>/dev/null || true) if [ "$result" = "ok" ]; then curl_rc=$(read_dl_status_field "$state_file" "curl_rc" 2>/dev/null || true) http_code=$(read_dl_status_field "$state_file" "http_code" 2>/dev/null || true) write_dl_status "$status_file" "fail" "${curl_rc:-state_conflict}" "$http_code" "状态收集异常" return 0 fi done for state_file in "$tmpd"/*.status; do [ -e "$state_file" ] || continue curl_rc=$(read_dl_status_field "$state_file" "curl_rc" 2>/dev/null || true) http_code=$(read_dl_status_field "$state_file" "http_code" 2>/dev/null || true) reason=$(read_dl_status_field "$state_file" "reason" 2>/dev/null || true) write_dl_status "$status_file" "fail" "$curl_rc" "$http_code" "$reason" return 0 done write_dl_status "$status_file" "fail" "state_missing" "" "状态采集失败" } print_fail_lines_from_block() { local block="$1" line label while IFS= read -r line; do [ -z "$line" ] && continue label="${line%%[*}" [ -n "$label" ] && printf ' %s FAIL\n' "$label" done <<< "$block" } dl_file() { local path="$1" output="$2" timeout="${3:-10}" quiet="${4:-0}" fixed_url="${5:-}" status_file="${6:-}" cache_suffix="" url local urls=() i pid pids=() tmpd [ -n "$SKIP_CACHE" ] && cache_suffix="?r=$RANDOM" tmpd=$(mktemp -d /tmp/dl.XXXXXX) 2>/dev/null || { write_dl_status "$status_file" "fail" "local_mkdir" "" "临时目录创建失败" return 1 } if [ -n "$fixed_url" ]; then urls=("$fixed_url") else for url in $(cdn_url_order "$SELECTED_CDN"); do urls+=("$url") done fi for i in "${!urls[@]}"; do ( local curl_rc http_code reason if curl -fsSL --connect-timeout "$timeout" --max-time "$((timeout * 8))" -w '%{http_code}' -o "$tmpd/$i.part" "${urls[$i]}/$path${cache_suffix}" > "$tmpd/$i.code" 2> "$tmpd/$i.err"; then http_code="" [ -s "$tmpd/$i.code" ] && IFS= read -r http_code < "$tmpd/$i.code" write_dl_status "$tmpd/$i.status" "ok" "0" "$http_code" "" mv "$tmpd/$i.part" "$tmpd/$i.done" else curl_rc=$? http_code="" [ -s "$tmpd/$i.code" ] && IFS= read -r http_code < "$tmpd/$i.code" reason=$(classify_curl_failure "$curl_rc" "$http_code") write_dl_status "$tmpd/$i.status" "fail" "$curl_rc" "$http_code" "$reason" fi ) & pids+=($!) done local remaining=("${pids[@]}") while [ ${#remaining[@]} -gt 0 ]; do if dl_capture_success_from_tmpd "$tmpd" "$output" "$status_file"; then for pid in "${remaining[@]}"; do kill "$pid" 2>/dev/null || true done rm -rf "$tmpd" [ "$quiet" = "1" ] || printf ' ✔ %s\n' "$path" return 0 fi sleep 0.1 local new_remaining=() for pid in "${remaining[@]}"; do kill -0 "$pid" 2>/dev/null && new_remaining+=("$pid") done remaining=("${new_remaining[@]}") done if dl_capture_success_from_tmpd "$tmpd" "$output" "$status_file"; then rm -rf "$tmpd" [ "$quiet" = "1" ] || printf ' ✔ %s\n' "$path" return 0 fi dl_capture_failure_from_tmpd "$tmpd" "$status_file" rm -rf "$tmpd" [ "$quiet" = "1" ] || printf ' %s FAIL\n' "$path" return 1 } release_source_candidates() { local url if [ -n "$SKIP_CACHE" ]; then printf '%s\n' "$INSTALLER_CDN" return 0 fi if [ -n "$FORCED_CDN_URL" ]; then printf '%s\n' "$FORCED_CDN_URL" return 0 fi for url in $(cdn_url_order "$SELECTED_CDN"); do printf '%s\n' "$url" done } # Description: 优先下载 gz 包解压到 WORKDIR/ext/,失败则回退单文件下载 gz_extract() { [ -n "$GZ_DONE" ] && return 1 local gz_file="$WORKDIR/ext.tar.gz" dl_file "ext.tar.gz" "$gz_file" 15 || return 1 if extract_ext_package "$gz_file" "$WORKDIR" && [ -d "$WORKDIR/ext" ]; then rm -f "$gz_file" GZ_DONE=1 return 0 fi rm -f "$gz_file" printf ' ext.tar.gz FAIL\n' return 1 } prepare_core_payloads() { local source gz_file gz_status_file gz_reason gz_category local bundle_time ext_bundle_time bundle_version ext_bundle_version CORE_PREP_FAILED="" CORE_BUNDLE_META_TIME="" CORE_EXT_BUNDLE_TIME="" CORE_PREP_HAS_REMOTE_FETCH=0 CORE_PREP_HAS_LOCAL_WRITE=0 CORE_PREP_HAS_INCONSISTENT=0 command -v tar >/dev/null 2>&1 || { CORE_PREP_HAS_LOCAL_WRITE=1 append_multiline_var CORE_PREP_FAILED "ext.tar.gz[解压失败]" return 1 } for source in $(release_source_candidates); do rm -rf "$WORKDIR/ext" 2>/dev/null || true rm -f "$WORKDIR/j-tools" "$WORKDIR/ext.tar.gz" "$WORKDIR/bundle_meta" \ "$WORKDIR/ext.tar.gz.status" 2>/dev/null || true gz_file="$WORKDIR/ext.tar.gz" gz_status_file="$WORKDIR/ext.tar.gz.status" CORE_PREP_FAILED="" CORE_BUNDLE_META_TIME="" CORE_EXT_BUNDLE_TIME="" CORE_PREP_HAS_REMOTE_FETCH=0 CORE_PREP_HAS_LOCAL_WRITE=0 CORE_PREP_HAS_INCONSISTENT=0 if ! dl_file "ext.tar.gz" "$gz_file" 15 1 "$source" "$gz_status_file"; then gz_reason=$(download_failure_reason_from_state "$gz_status_file") gz_category=$(download_failure_category_from_state "$gz_status_file") [ "$gz_category" = "local_write" ] && CORE_PREP_HAS_LOCAL_WRITE=1 || CORE_PREP_HAS_REMOTE_FETCH=1 append_multiline_var CORE_PREP_FAILED "ext.tar.gz[下载失败:$gz_reason]" continue fi LOCKED_CDN="$source" SELECTED_CDN="$source" extract_ext_package "$gz_file" "$WORKDIR" || { CORE_PREP_HAS_INCONSISTENT=1 append_multiline_var CORE_PREP_FAILED "ext.tar.gz[解压失败]" continue } [ -d "$WORKDIR/ext" ] && [ -s "$WORKDIR/j-tools" ] && [ -s "$WORKDIR/bundle_meta" ] || { CORE_PREP_HAS_INCONSISTENT=1 append_multiline_var CORE_PREP_FAILED "ext.tar.gz[目录缺失]" continue } validate_bash_file "$WORKDIR/j-tools" || { CORE_PREP_HAS_INCONSISTENT=1 append_multiline_var CORE_PREP_FAILED "j-tools[语法校验失败]" continue } validate_bundle_meta "$WORKDIR/bundle_meta" bundle_time bundle_version || { CORE_PREP_HAS_INCONSISTENT=1 append_multiline_var CORE_PREP_FAILED "bundle_meta[字段无效]" continue } validate_bundle_meta "$WORKDIR/ext/.bundle_meta" ext_bundle_time ext_bundle_version || { CORE_PREP_HAS_INCONSISTENT=1 append_multiline_var CORE_PREP_FAILED "ext/.bundle_meta[字段无效]" continue } if [ "$ext_bundle_time" != "$bundle_time" ]; then CORE_PREP_HAS_INCONSISTENT=1 CORE_BUNDLE_META_TIME="$bundle_time" CORE_EXT_BUNDLE_TIME="$ext_bundle_time" append_multiline_var CORE_PREP_FAILED "bundle_meta[与 ext/.bundle_meta 不一致]" continue fi if [ "$ext_bundle_version" != "$bundle_version" ]; then CORE_PREP_HAS_INCONSISTENT=1 append_multiline_var CORE_PREP_FAILED "bundle_meta[version 与 ext/.bundle_meta 不一致]" continue fi rm -f "$gz_file" "$WORKDIR/ext.tar.gz.status" 2>/dev/null || true GZ_DONE=1 CORE_PREP_FAILED="" CORE_PREP_HAS_REMOTE_FETCH=0 CORE_PREP_HAS_LOCAL_WRITE=0 CORE_PREP_HAS_INCONSISTENT=0 printf ' ✔ %s\n' "ext.tar.gz(用户全量包)" return 0 done rm -f "$WORKDIR/ext.tar.gz" "$WORKDIR/bundle_meta" "$WORKDIR/j-tools" 2>/dev/null || true print_fail_lines_from_block "$CORE_PREP_FAILED" return 1 } validate_bash_file() { [ -f "$1" ] && [ -s "$1" ] && bash -n "$1" 2>/dev/null } # Description: 严格校验 built_at_shanghai 值(仅接受 YYYY-MM-DD HH:MM) # $1: 待校验字符串 # Returns: 0 = 合法, 1 = 非法 jt_meta_time_valid() { local s="${1:-}" y m d h mi dim case "$s" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\ [0-9][0-9]:[0-9][0-9]) ;; *) return 1 ;; esac y=${s:0:4}; m=${s:5:2}; d=${s:8:2}; h=${s:11:2}; mi=${s:14:2} case "$m" in 0[1-9]|1[0-2]) ;; *) return 1 ;; esac case "$d" in 0[1-9]|[12][0-9]|3[01]) ;; *) return 1 ;; esac case "$h" in 0[0-9]|1[0-9]|2[0-3]) ;; *) return 1 ;; esac case "$mi" in [0-5][0-9]) ;; *) return 1 ;; esac case "$m" in 0[13578]|1[02]) dim=31 ;; 0[469]|11) dim=30 ;; 02) if [ $((y % 4)) -eq 0 ] && { [ $((y % 100)) -ne 0 ] || [ $((y % 400)) -eq 0 ]; }; then dim=29; else dim=28; fi ;; esac [ "$d" -le "$dim" ] || return 1 return 0 } read_bundle_built_at() { local meta="$1" val dt cnt [ -f "$meta" ] || return 1 cnt=$(grep -cE '^built_at_shanghai=' "$meta" 2>/dev/null) [ "$cnt" = "1" ] || return 1 val=$(sed -n 's/^built_at_shanghai=//p' "$meta" 2>/dev/null | head -n 1) [ -n "$val" ] || return 1 dt="" case "$val" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\ [0-9][0-9]:[0-9][0-9]) dt="$val" ;; \'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\ [0-9][0-9]:[0-9][0-9]\') dt="${val#\'}"; dt="${dt%\'}" ;; \"[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]\ [0-9][0-9]:[0-9][0-9]\") dt="${val#\"}"; dt="${dt%\"}" ;; *) return 1 ;; esac jt_meta_time_valid "$dt" || return 1 printf '%s' "$dt" } read_bundle_version() { local meta="$1" line [ -f "$meta" ] || return 1 line=$(sed -n 's/^version=//p' "$meta" 2>/dev/null | head -n 1) [ -n "$line" ] || return 1 line=${line#\'} line=${line%\'} line=${line#\"} line=${line%\"} printf '%s' "$line" | grep -Eq '^v[0-9]{4}\.[0-9]{2}\.[0-9]+$' || return 1 printf '%s' "$line" } validate_bundle_meta() { local meta="$1" built_at_var="${2:-}" version_var="${3:-}" local built_at version [ -s "$meta" ] || return 1 built_at=$(read_bundle_built_at "$meta" 2>/dev/null) || return 1 version=$(read_bundle_version "$meta" 2>/dev/null) || return 1 if [ -n "$built_at_var" ]; then printf -v "$built_at_var" '%s' "$built_at" fi if [ -n "$version_var" ]; then printf -v "$version_var" '%s' "$version" fi return 0 } ensure_bundle_meta_file() { local meta="$WORKDIR/ext/.bundle_meta" validate_bundle_meta "$meta" && return 0 rm -f "$meta" 2>/dev/null || true [ -n "$LOCKED_CDN" ] || return 1 dl_file "bundle_meta" "$meta" 10 1 "$LOCKED_CDN" || return 1 validate_bundle_meta "$meta" || { rm -f "$meta" 2>/dev/null || true return 1 } } download_menu_file() { local remote_path="$1" local local_key="$2" local output_path="$3" local rel_path="${output_path#$WORKDIR/}" mkdir -p "$(dirname "$output_path")" 2>/dev/null || true dl_file "$remote_path" "$output_path" 10 0 "$LOCKED_CDN" || { menu_failed="$menu_failed $local_key[下载失败]" MENU_FAILED_TARGETS+=("$rel_path") rm -f "$output_path" 2>/dev/null || true return 1 } validate_bash_file "$output_path" || { menu_failed="$menu_failed $local_key[校验失败]" MENU_FAILED_TARGETS+=("$rel_path") rm -f "$output_path" 2>/dev/null || true return 1 } } write_source_conf() { # 同目录临时文件 + 0600 + 非空校验 + 原子 mv;失败保留已有正式文件 local conf="$TOOL_DIR/source.conf" local dir tmp rc=0 dir=$(dirname "$conf") mkdir -p "$dir" 2>/dev/null || return 1 tmp=$(mktemp "$dir/.source.conf.XXXXXX" 2>/dev/null) || return 1 # 先收紧权限再写内容 chmod 0600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } if ! printf 'TOOL_URL=%s\n' "$LOCKED_CDN" >"$tmp"; then rm -f "$tmp" return 1 fi # 完整写入校验:非空且包含 TOOL_URL= if [ ! -s "$tmp" ] || ! grep -q '^TOOL_URL=' "$tmp" 2>/dev/null; then rm -f "$tmp" return 1 fi # 再次确保 0600(部分 umask 场景) chmod 0600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } if ! mv -f "$tmp" "$conf"; then rm -f "$tmp" return 1 fi chmod 0600 "$conf" 2>/dev/null || true return 0 } write_j_launcher() { local rc=0 launcher_paths_valid || return 1 # REL-03: 目标与符号链接路径可参数化(fixture 隔离用,默认与既有路径一致);失败返回真实 RC 以触发回滚 if ! cat > "$LAUNCHER_TARGET" << EOF #!/bin/bash TOOL_DIR="$TOOL_DIR" if [ ! -f "\$TOOL_DIR/j-tools" ]; then printf '%s\n' "工具箱文件丢失,请重新安装" exit 1 fi pwd >/dev/null 2>&1 || cd / 2>/dev/null || cd "\$TOOL_DIR" 2>/dev/null || true JTOOLS_DIR="\$TOOL_DIR" exec "\$TOOL_DIR/j-tools" "\$@" EOF then rc=1 fi chmod +x "$LAUNCHER_TARGET" 2>/dev/null || rc=1 ln -sf "$LAUNCHER_TARGET" "$LAUNCHER_LINK" 2>/dev/null || rc=1 return "$rc" } BACKUP_DIR="$WORKDIR/rollback" BACKUP_FILES=() BACKUP_TARGETS=() BACKUP_MODES=() NEW_TARGETS=() # REL-03: launcher 路径可参数化(fixture 隔离用);默认与既有路径一致 LAUNCHER_TARGET="${LAUNCHER_TARGET:-/usr/local/bin/j}" LAUNCHER_LINK="${LAUNCHER_LINK:-/usr/local/bin/J}" launcher_paths_valid() { [ -n "${LAUNCHER_TARGET:-}" ] && [ -n "${LAUNCHER_LINK:-}" ] && [ "$LAUNCHER_TARGET" != "$LAUNCHER_LINK" ] } post_commit_self_check() { local live_meta="$TOOL_DIR/ext/.bundle_meta" work_meta="$WORKDIR/ext/.bundle_meta" local live_built_at work_built_at live_version work_version [ -n "$UPDATE_MODE" ] || return 0 [ -f "$TOOL_DIR/j-tools" ] && [ ! -L "$TOOL_DIR/j-tools" ] && [ -s "$TOOL_DIR/j-tools" ] && [ -x "$TOOL_DIR/j-tools" ] && bash -n "$TOOL_DIR/j-tools" 2>/dev/null || return 1 [ -f "$live_meta" ] && [ ! -L "$live_meta" ] && [ -s "$live_meta" ] || return 1 validate_bundle_meta "$live_meta" || return 1 [ -f "$work_meta" ] && [ ! -L "$work_meta" ] && [ -s "$work_meta" ] || return 1 validate_bundle_meta "$work_meta" || return 1 live_built_at=$(read_bundle_built_at "$live_meta" 2>/dev/null) || return 1 work_built_at=$(read_bundle_built_at "$work_meta" 2>/dev/null) || return 1 [ "$live_built_at" = "$work_built_at" ] || return 1 live_version=$(read_bundle_version "$live_meta" 2>/dev/null) || return 1 work_version=$(read_bundle_version "$work_meta" 2>/dev/null) || return 1 [ "$live_version" = "$work_version" ] || return 1 [ -f "$LAUNCHER_TARGET" ] && [ ! -L "$LAUNCHER_TARGET" ] && [ -s "$LAUNCHER_TARGET" ] && [ -x "$LAUNCHER_TARGET" ] || return 1 launcher_paths_valid || return 1 return 0 } # REL-03: 更新写阶段事务状态;"armed"= 已备份且写阶段进行中(失败必须回滚或保留恢复材料) TX_STATE="" # REL-03: 回滚失败标记;为 1 时 EXIT 陷阱保留 WORKDIR 内的 rollback 恢复材料 ROLLBACK_FAILED="" # REL-03: 更新写阶段中止协调(信号路径经此协调,不得在有效回滚前清除或报成功) rel03_tx_abort() { local signal="${1:-}" exit_code=1 case "$signal" in INT) exit_code=130 ;; TERM) exit_code=143 ;; esac # Ignore further signals while the single rollback attempt is in progress. if [ "${REL03_TX_ABORTING:-}" = "1" ]; then exit "$exit_code" fi REL03_TX_ABORTING=1 trap ':' INT TERM if [ -n "$UPDATE_MODE" ] && [ "$TX_STATE" = "armed" ] && [ "$ROLLBACK_FAILED" != "1" ]; then if rollback_update_write; then TX_STATE="" printf '\n%b\n' "${RED}更新写入失败,已回退${RESET}" else ROLLBACK_FAILED=1 TX_STATE="armed" printf '\n%b\n' "${RED}更新失败且回滚未完成${RESET}" fi fi exit "$exit_code" } trap 'rel03_tx_abort INT' INT trap 'rel03_tx_abort TERM' TERM backup_existing_target() { local target="$1" slot mode if [ -e "$target" ] || [ -L "$target" ]; then mkdir -p "$BACKUP_DIR" 2>/dev/null || return 1 slot=${#BACKUP_TARGETS[@]} cp -P "$target" "$BACKUP_DIR/$slot" 2>/dev/null || return 1 # REL-03: 记录备份 mode,回滚时用于恢复既有普通文件的权限 mode=$(stat -c '%a' "$target" 2>/dev/null) || return 1 [[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 BACKUP_FILES+=("$BACKUP_DIR/$slot") BACKUP_TARGETS+=("$target") BACKUP_MODES+=("$mode") else NEW_TARGETS+=("$target") fi } prepare_update_backups() { local file target launcher_paths_valid || return 1 BACKUP_FILES=() BACKUP_TARGETS=() BACKUP_MODES=() NEW_TARGETS=() backup_existing_target "$TOOL_DIR/j-tools" || return 1 backup_existing_target "$TOOL_DIR/source.conf" || return 1 backup_existing_target "$LAUNCHER_TARGET" || return 1 backup_existing_target "$LAUNCHER_LINK" || return 1 if [ -d "$WORKDIR/ext" ]; then while IFS= read -r -d '' file; do target="$TOOL_DIR/${file#$WORKDIR/}" backup_existing_target "$target" || return 1 done < <(find "$WORKDIR/ext" -type f -print0 2>/dev/null) fi } is_managed_payload_target() { case "$1" in ext/.bundle_meta|ext/apps.sh|ext/cpu_guard.sh|ext/cscan|ext/jtcdn_test|ext/jtprobe|ext/proxies.sh|ext/scanner|ext/security.sh|ext/tests.sh|ext/web.sh|\ ext/bundle/core/app_access_contract.sh|ext/bundle/core/app_market.sh|ext/bundle/core/docker_app.sh|ext/bundle/core/ctl.sh|ext/bundle/core/ctl_json.sh|ext/bundle/core/ctl_read.sh|ext/bundle/core/ctl_read_app.sh|ext/bundle/core/ctl_read_core.sh|ext/bundle/core/ctl_read_system.sh|ext/bundle/core/ctl_read_network.sh|ext/bundle/core/ctl_read_web.sh|ext/bundle/core/ctl_write.sh|\ ext/bundle/modules/compose_apps.sh|ext/bundle/modules/download_clients.sh|\ ext/bundle/modules/emby.sh|ext/bundle/modules/panel_tools.sh|ext/bundle/modules/media_automation_apps.sh|ext/bundle/modules/media_combo.sh|ext/bundle/modules/download_tools.sh|\ ext/bundle/modules/cloud_sub_apps.sh|ext/bundle/modules/single_container_apps.sh|\ ext/bundle/modules/emby/config.yml|ext/bundle/modules/emby/docker-compose.yml|\ ext/webui/server.py|ext/webui/static/index.html|ext/webui/static/app.css|ext/webui/static/app.js|ext/webui/jtools-webui.service|ext/webui/API.md|\ ext/switch-exit/src/auto-revert.sh|ext/switch-exit/src/boot-guard.sh|ext/switch-exit/src/config.py|ext/switch-exit/src/fetch_subscription.py|\ ext/switch-exit/src/install.sh|ext/switch-exit/src/lib.sh|ext/switch-exit/src/parse_links.py|ext/switch-exit/src/payload-current.sh|\ ext/switch-exit/src/platform.sh|ext/switch-exit/src/route.sh|ext/switch-exit/src/switch-exit|ext/switch-exit/src/uninstall.sh|\ ext/switch-exit/src/xray_config.py|ext/switch-exit/src/xray-start.sh|ext/switch-exit/src/xray-stop.sh) return 0 ;; *) return 1 ;; esac } menu_failed_target() { local target="$1" failed_target for failed_target in "${MENU_FAILED_TARGETS[@]}"; do [ "$failed_target" = "$target" ] && return 0 done return 1 } collect_stale_targets() { local file rel STALE_TARGETS=() [ -d "$TOOL_DIR/ext" ] || return 0 while IFS= read -r -d "" file; do case "$file" in "$TOOL_DIR/ext/bundle/self/"*|"$TOOL_DIR/ext/switch-exit/AGENTS.md"|"$TOOL_DIR/ext/switch-exit/README.md"|"$TOOL_DIR/ext/switch-exit/测试清单.md"|"$TOOL_DIR/ext/switch-exit/设计说明.md"|*/__pycache__/*|*.pyc|*.pyo) continue ;; esac rel="${file#$TOOL_DIR/}" is_managed_payload_target "$rel" || continue menu_failed_target "$rel" && continue [ -e "$WORKDIR/$rel" ] || { backup_existing_target "$file" || return 1; STALE_TARGETS+=("$file"); } done < <(find "$TOOL_DIR/ext" -type f -print0 2>/dev/null) } delete_stale_targets() { local target for target in "${STALE_TARGETS[@]}"; do rm -f "$target" 2>/dev/null || return 1 done return 0 } rollback_update_write() { local i target mode [ "${#BACKUP_FILES[@]}" -eq "${#BACKUP_TARGETS[@]}" ] || return 1 [ "${#BACKUP_FILES[@]}" -eq "${#BACKUP_MODES[@]}" ] || return 1 for i in "${!BACKUP_TARGETS[@]}"; do target="${BACKUP_TARGETS[$i]}" mode="${BACKUP_MODES[$i]}" [[ "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 [ -n "${BACKUP_FILES[$i]}" ] || return 1 [ -e "${BACKUP_FILES[$i]}" ] || [ -L "${BACKUP_FILES[$i]}" ] || return 1 [ -n "$target" ] || return 1 mkdir -p "$(dirname "$target")" 2>/dev/null || return 1 rm -f -- "$target" 2>/dev/null || return 1 cp -P -- "${BACKUP_FILES[$i]}" "$target" 2>/dev/null || return 1 if [ -f "$target" ] && [ ! -L "$target" ]; then chmod "$mode" -- "$target" 2>/dev/null || return 1 fi done for target in "${NEW_TARGETS[@]}"; do [ -n "$target" ] || return 1 rm -f -- "$target" 2>/dev/null || return 1 [ ! -e "$target" ] && [ ! -L "$target" ] || return 1 done return 0 } # REL-03: 更新写失败唯一决策路径;以 rollback_update_write 真实 RC 驱动事务状态与单一错误提示 handle_update_write_failure() { if [ -n "$UPDATE_MODE" ]; then if rollback_update_write; then TX_STATE="" printf '\n%b\n' "${RED}更新写入失败,已回退${RESET}" else ROLLBACK_FAILED=1 TX_STATE="armed" printf '\n%b\n' "${RED}更新失败且回滚未完成${RESET}" fi fi exit 1 } # B45-B: WebUI token + unit install (never enable/start) installer_webui_ensure_token() { local dir="/etc/j-tools" token_file="/etc/j-tools/webui.token" tmp="" token="" mkdir -p "$dir" 2>/dev/null || return 1 chmod 700 "$dir" 2>/dev/null || true if [ -e "$token_file" ] || [ -L "$token_file" ]; then if [ -L "$token_file" ] || [ ! -f "$token_file" ]; then printf '%b\n' "${YELLOW}WebUI token 路径不是普通文件(可能是 symlink),已跳过写入${RESET}" return 1 fi chmod 600 "$token_file" 2>/dev/null || true token=$(tr -d '\r\n\t ' <"$token_file" 2>/dev/null || true) if printf '%s' "$token" | grep -Eq '^[0-9a-f]{64}$'; then return 0 fi printf '%b\n' "${YELLOW}WebUI token 已存在但格式无效,已保留原文件${RESET}" return 1 fi token=$(tr -dc 'a-f0-9' /dev/null | head -c 64) if [ "${#token}" -ne 64 ] || ! printf '%s' "$token" | grep -Eq '^[0-9a-f]{64}$'; then if command -v python3 >/dev/null 2>&1; then token=$(python3 -c 'import secrets; print(secrets.token_hex(32))' 2>/dev/null || true) fi fi if ! printf '%s' "$token" | grep -Eq '^[0-9a-f]{64}$'; then printf '%b\n' "${YELLOW}WebUI token 生成失败,已跳过${RESET}" return 1 fi tmp=$(mktemp "$dir/.webui.token.XXXXXX" 2>/dev/null || true) [ -n "$tmp" ] || return 1 printf '%s\n' "$token" >"$tmp" || { rm -f "$tmp"; return 1; } chmod 600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } mv -f "$tmp" "$token_file" || { rm -f "$tmp"; return 1; } chmod 600 "$token_file" 2>/dev/null || true return 0 } installer_webui_ensure_port() { local dir="/etc/j-tools" port_file="$dir/webui.port" tmp="" port="" mkdir -p "$dir" 2>/dev/null || return 1 chmod 700 "$dir" 2>/dev/null || true if [ -e "$port_file" ] || [ -L "$port_file" ]; then [ -f "$port_file" ] && [ ! -L "$port_file" ] || return 1 port=$(tr -d '\r\n\t ' <"$port_file" 2>/dev/null || true) printf '%s' "$port" | grep -Eq '^[0-9]+$' && [ "$port" -ge 1024 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null return $? fi if command -v python3 >/dev/null 2>&1; then port=$(python3 -c 'import secrets; print(secrets.randbelow(64512) + 1024)' 2>/dev/null || true) fi if ! printf '%s' "$port" | grep -Eq '^[0-9]+$' || [ "$port" -lt 1024 ] 2>/dev/null || [ "$port" -gt 65535 ] 2>/dev/null; then printf '%b\n' "${YELLOW}随机 WebUI 端口生成失败,已跳过端口文件写入${RESET}" 2>/dev/null || true return 1 fi tmp=$(mktemp "$dir/.webui.port.XXXXXX" 2>/dev/null || true) [ -n "$tmp" ] || return 1 printf '%s\n' "$port" >"$tmp" || { rm -f "$tmp"; return 1; } chmod 600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } mv -f "$tmp" "$port_file" || { rm -f "$tmp"; return 1; } chmod 600 "$port_file" 2>/dev/null || true return 0 } installer_webui_install_unit() { local src="$TOOL_DIR/ext/webui/jtools-webui.service" local dst="/etc/systemd/system/jtools-webui.service" [ -f "$src" ] || return 1 if [ ! -d /etc/systemd/system ]; then return 1 fi cp "$src" "$dst" 2>/dev/null || return 1 chmod 644 "$dst" 2>/dev/null || true if command -v systemctl >/dev/null 2>&1; then # only reload unit catalog; never enable/start WebUI on source or install path systemctl daemon-reload 2>/dev/null || true fi return 0 } installer_webui_setup() { # Alpine: no WebUI (skip token/port/unit/daemon-reload) if [ -f /etc/alpine-release ] || grep -qi '^ID=alpine' /etc/os-release 2>/dev/null; then return 0 fi # best-effort; toolbox install/update must still succeed without python3 if [ ! -f "$TOOL_DIR/ext/webui/server.py" ]; then return 0 fi if ! command -v python3 >/dev/null 2>&1; then printf '%b\n' "${YELLOW}未检测到 python3,WebUI 载荷已安装但不可用(未装依赖)${RESET}" return 0 fi installer_webui_ensure_token || true installer_webui_ensure_port || true installer_webui_install_unit || true return 0 } cleanup_update_backups() { local file for file in "${BACKUP_FILES[@]}"; do rm -f "$file" 2>/dev/null || true done } core_failed="" menu_failed="" MENU_FAILED_TARGETS=() GZ_DONE="" core_has_remote_fetch=0 core_has_local_write=0 core_has_release_inconsistency=0 if [ -n "$UPDATE_MODE" ]; then printf '正在更新工具箱...\n' else printf '正在安装工具箱...\n' fi # ---- 核心载荷并行准备 ---- if ! prepare_core_payloads; then [ -n "$CORE_PREP_FAILED" ] && append_multiline_var core_failed "$CORE_PREP_FAILED" || append_multiline_var core_failed "ext.tar.gz[准备失败]" core_has_remote_fetch=$CORE_PREP_HAS_REMOTE_FETCH core_has_local_write=$CORE_PREP_HAS_LOCAL_WRITE core_has_release_inconsistency=$CORE_PREP_HAS_INCONSISTENT fi if [ -n "$GZ_DONE" ] && ! validate_bash_file "$WORKDIR/j-tools"; then append_multiline_var core_failed "j-tools[语法校验失败]" core_has_release_inconsistency=1 fi if [ -n "$GZ_DONE" ]; then if ! validate_bash_file "$WORKDIR/ext/web.sh"; then rm -f "$WORKDIR/ext/web.sh" 2>/dev/null || true dl_file "ext/web.sh" "$WORKDIR/ext/web.sh" 10 0 "$LOCKED_CDN" if ! validate_bash_file "$WORKDIR/ext/web.sh"; then append_multiline_var core_failed "ext/web.sh[校验失败]" core_has_release_inconsistency=1 fi fi for mod in apps.sh tests.sh proxies.sh security.sh; do if ! validate_bash_file "$WORKDIR/ext/$mod"; then rm -f "$WORKDIR/ext/$mod" 2>/dev/null || true download_menu_file "ext/$mod" "$mod" "$WORKDIR/ext/$mod" fi done for mod in app_market.sh docker_app.sh; do if ! validate_bash_file "$WORKDIR/ext/bundle/core/$mod"; then rm -f "$WORKDIR/ext/bundle/core/$mod" 2>/dev/null || true download_menu_file "ext/bundle/core/$mod" "$mod" "$WORKDIR/ext/bundle/core/$mod" fi done for mod in \ cloud_sub_apps.sh \ emby.sh panel_tools.sh media_combo.sh download_tools.sh \ single_container_apps.sh; do if ! validate_bash_file "$WORKDIR/ext/bundle/modules/$mod"; then rm -f "$WORKDIR/ext/bundle/modules/$mod" 2>/dev/null || true download_menu_file "ext/bundle/modules/$mod" "$mod" "$WORKDIR/ext/bundle/modules/$mod" fi done for spec in "cpu_guard.sh|ext/cpu_guard.sh|cpu_guard.sh"; do IFS='|' read -r rel remote_path local_key <<< "$spec" if ! validate_bash_file "$WORKDIR/ext/$rel"; then rm -f "$WORKDIR/ext/$rel" 2>/dev/null || true download_menu_file "$remote_path" "$local_key" "$WORKDIR/ext/$rel" fi done fi ensure_bundle_meta_file >/dev/null 2>&1 || true # ---- 核心失败 → 中止 ---- if [ -n "$core_failed" ]; then printf '\n' [ -n "$LOCKED_CDN" ] && mkdirdi_status "发布源" "$(cdn_name "$LOCKED_CDN")" "$YELLOW" [ -n "$LOCKED_CDN" ] && mkdirdi_status "发布地址" "$LOCKED_CDN" "$YELLOW" while IFS= read -r f; do [ -z "$f" ] && continue case "$f" in *"[下载失败:"*"]") label="${f%%[*}" detail="${f#*[下载失败:}" detail="${detail%]}" mkdirdi_status "$label" "下载失败($detail)" "$RED" ;; *"[语法校验失败]") mkdirdi_status "${f%\[*}" "语法校验失败" "$RED" ;; *"[解压失败]") mkdirdi_status "${f%\[*}" "解压失败" "$RED" ;; *"[目录缺失]") mkdirdi_status "${f%\[*}" "目录缺失" "$RED" ;; *"[字段无效]") mkdirdi_status "${f%\[*}" "字段无效" "$RED" ;; *"[与 ext/.bundle_meta 不一致]") mkdirdi_status "${f%\[*}" "与 ext/.bundle_meta 不一致" "$RED" ;; *"[写入 ext/.bundle_meta 失败]") mkdirdi_status "${f%\[*}" "写入 ext/.bundle_meta 失败" "$RED" ;; *) mkdirdi_status "$f" "校验失败" "$RED" ;; esac done <<< "$core_failed" if [ -n "$CORE_BUNDLE_META_TIME" ] || [ -n "$CORE_EXT_BUNDLE_TIME" ]; then [ -n "$CORE_BUNDLE_META_TIME" ] && mkdirdi_status "顶层 bundle_meta" "$CORE_BUNDLE_META_TIME" "$YELLOW" [ -n "$CORE_EXT_BUNDLE_TIME" ] && mkdirdi_status "包内 ext/.bundle_meta" "$CORE_EXT_BUNDLE_TIME" "$YELLOW" fi if [ "$core_has_local_write" -eq 1 ]; then mkdirdi_status "排查建议" "检查 /tmp 可写性与磁盘空间" "$YELLOW" fi if [ -n "$UPDATE_MODE" ]; then if [ "$core_has_release_inconsistency" -eq 1 ]; then printf '\n%b\n' "${RED}核心发布物彼此不一致或已损坏,更新中止${RESET}" elif [ "$core_has_local_write" -eq 1 ]; then printf '\n%b\n' "${RED}本地临时写入环境异常,更新中止${RESET}" else printf '\n%b\n' "${RED}核心发布集文件获取失败,更新中止${RESET}" fi else if [ "$core_has_release_inconsistency" -eq 1 ]; then if [ -n "$SKIP_CACHE" ]; then printf '\n%b\n' "${RED}主站 tools.88de.de 发布物彼此不一致或已损坏,安装中止${RESET}" else printf '\n%b\n' "${RED}核心发布物彼此不一致或已损坏,安装中止${RESET}" fi elif [ "$core_has_local_write" -eq 1 ]; then printf '\n%b\n' "${RED}本地临时写入环境异常,安装中止${RESET}" else if [ -n "$SKIP_CACHE" ]; then printf '\n%b\n' "${RED}主站 tools.88de.de 文件获取失败,安装中止${RESET}" else printf '\n%b\n' "${RED}核心发布集文件获取失败,安装中止${RESET}" fi fi printf '%b\n' "${GRAY}请重新执行: bash <(curl -sL tools.88de.de)${RESET}" fi exit 1 fi # ---- 写入 ---- mkdir -p "$TOOL_DIR/ext/bundle/core" "$TOOL_DIR/ext/bundle/modules" "$TOOL_DIR/ext/bundle/self" || exit 1 if [ -n "$UPDATE_MODE" ]; then # B42-F1: backups first; only on success collect managed stale (before copy). # Stale deletion remains after successful j-tools/ext copy below. prepare_update_backups || { printf '\n%b\n' "${RED}更新备份失败,已取消${RESET}" exit 1 } collect_stale_targets || { printf '\n%b\n' "${RED}旧载荷清理清单准备失败,已取消${RESET}" exit 1 } TX_STATE="armed" fi write_failed=0 if [ -f "$WORKDIR/j-tools" ]; then cp "$WORKDIR/j-tools" "$TOOL_DIR/j-tools" 2>/dev/null || write_failed=1 [ "$write_failed" -eq 0 ] && chmod 755 "$TOOL_DIR/j-tools" 2>/dev/null || true else write_failed=1 fi if [ "$write_failed" -eq 0 ] && [ -d "$WORKDIR/ext" ]; then cp -R "$WORKDIR/ext/." "$TOOL_DIR/ext/" 2>/dev/null || write_failed=1 if [ "$write_failed" -eq 0 ] && [ -n "$UPDATE_MODE" ]; then delete_stale_targets || write_failed=1 fi fi [ "$write_failed" -eq 0 ] && write_source_conf || write_failed=1 [ "$write_failed" -eq 0 ] && write_j_launcher || write_failed=1 [ "$write_failed" -eq 0 ] && post_commit_self_check || write_failed=1 if [ "$write_failed" -eq 1 ]; then handle_update_write_failure fi for f in "$TOOL_DIR/j-tools" "$TOOL_DIR/ext"/*.sh "$TOOL_DIR/ext/bundle/core"/*.sh "$TOOL_DIR/ext/bundle/modules"/*.sh "$TOOL_DIR/ext/bundle/self"/*.sh; do [ -f "$f" ] && chmod +x "$f" 2>/dev/null || true done rm -f "$TOOL_DIR/.version" 2>/dev/null || true if [ -n "$UPDATE_MODE" ]; then TX_STATE="" cleanup_update_backups fi installer_webui_setup if [ -n "$menu_failed" ]; then if [ -n "$UPDATE_MODE" ]; then printf '\n%b\n' "${YELLOW}以下附加文件未更新,已跳过:${RESET}" else printf '\n%b\n' "${YELLOW}以下附加文件未安装,已跳过:${RESET}" fi for f in $menu_failed; do mkdirdi_status "$f" "已跳过" "$YELLOW" done fi if [ -n "$UPDATE_MODE" ]; then finish_text=$(finish_elapsed_text 2>/dev/null || true) if [ -n "$finish_text" ]; then printf '\n%s\n\n' "更新完成!(耗时:$finish_text)" else printf '\n%s\n\n' "更新完成!" fi exit 0 fi finish_text=$(finish_elapsed_text 2>/dev/null || true) if [ -n "$finish_text" ]; then printf '\n%s\n%s\n' "安装完成!(耗时:$finish_text)" "快捷启动命令:j 或 J" else printf '\n%s\n%s\n' "安装完成!" "快捷启动命令:j 或 J" fi printf '\n' installer_ensure_safe_cwd JTOOLS_SKIP_CLEAR_ONCE=1 JTOOLS_DIR="$TOOL_DIR" exec "$TOOL_DIR/j-tools"