Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions install.sh
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ install_local_dashboard_assets
ensure_controller_secret >/dev/null
set_shell_proxy_persist_enabled "false"

# ── TUI 可选安装 ──────────────────────────────────────────
prompt_install_tui

ensure_subscription_bootstrap_for_install "default"
prompt_subscription_if_needed

Expand Down
3 changes: 3 additions & 0 deletions scripts/core/clashctl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ source "$PROJECT_DIR/scripts/core/config.sh"
source "$PROJECT_DIR/scripts/core/completion.sh"
source "$PROJECT_DIR/scripts/core/proxy.sh"
source "$PROJECT_DIR/scripts/core/update.sh"
source "$PROJECT_DIR/scripts/core/tui.sh"
source "$PROJECT_DIR/scripts/init/systemd.sh"
source "$PROJECT_DIR/scripts/init/systemd-user.sh"
source "$PROJECT_DIR/scripts/init/script.sh"
Expand All @@ -34,6 +35,7 @@ Usage:
ls 📡 查看订阅列表

🕹️ Control:
tui 🖥️ TUI 交互式控制台(需要 gum)
clashui 🕹️ 查看 Web 控制台
secret 🔑 查看或设置 Web 密钥
clashsecret 🔑 查看或设置 Web 密钥
Expand Down Expand Up @@ -7279,6 +7281,7 @@ case "$cmd" in
proxy) cmd_proxy "$@" ;;
upgrade) cmd_upgrade "$@" ;;
update) cmd_update "$@" ;;
tui) cmd_tui "$@" ;;
completion) cmd_completion "$@" ;;
start-direct) cmd_start_direct "$@" ;;
stop-direct) cmd_stop_direct "$@" ;;
Expand Down
82 changes: 82 additions & 0 deletions scripts/core/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2898,6 +2898,88 @@ install_runtime_brief_line() {
esac
}

# ─── TUI 安装 ──────────────────────────────────────────────
install_gum() {
local arch
arch="$(uname -m)"
case "$arch" in
x86_64) arch="amd64" ;;
aarch64) arch="arm64" ;;
armv7*) arch="armv7" ;;
*) echo "❗ 不支持的架构:$arch,请手动安装 gum"; return 1 ;;
esac

local version="0.14.5"
local tmp_deb
tmp_deb="$(mktemp --suffix=.deb 2>/dev/null || mktemp)"

if command -v apt-get >/dev/null 2>&1; then
local url="https://github.com/charmbracelet/gum/releases/download/v${version}/gum_${version}_${arch}.deb"
echo "📥 正在下载 gum v${version} (${arch})..."
if curl -fsSL -o "$tmp_deb" "$url"; then
dpkg -i "$tmp_deb" >/dev/null 2>&1 || apt-get install -f -y >/dev/null 2>&1
rm -f "$tmp_deb" 2>/dev/null || true
else
rm -f "$tmp_deb" 2>/dev/null || true
echo "❗ 下载失败,请手动安装 gum"
return 1
fi
elif command -v yum >/dev/null 2>&1 || command -v dnf >/dev/null 2>&1; then
local url="https://github.com/charmbracelet/gum/releases/download/v${version}/gum_${version}_${arch}.rpm"
echo "📥 正在下载 gum v${version} (${arch})..."
if curl -fsSL -o "$tmp_deb" "$url"; then
rpm -i "$tmp_deb" >/dev/null 2>&1 || true
rm -f "$tmp_deb" 2>/dev/null || true
else
rm -f "$tmp_deb" 2>/dev/null || true
echo "❗ 下载失败,请手动安装 gum"
return 1
fi
elif command -v pacman >/dev/null 2>&1; then
pacman -S --noconfirm gum 2>/dev/null || { echo "❗ 安装失败,请手动安装 gum"; return 1; }
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache gum 2>/dev/null || { echo "❗ 安装失败,请手动安装 gum"; return 1; }
else
echo "❗ 未检测到支持的包管理器,请手动安装 gum"
echo " 见 https://github.com/charmbracelet/gum#installation"
return 1
fi

if command -v gum >/dev/null 2>&1; then
echo "✅ gum 安装成功"
return 0
else
echo "❗ gum 安装失败"
return 1
fi
}

prompt_install_tui() {
if command -v gum >/dev/null 2>&1; then
echo "🖥️ TUI 依赖已就绪(gum $(gum --version 2>/dev/null || echo '?'))"
return 0
fi

echo
echo "🖥️ Clash TUI 控制台需要安装 gum(Charm 出品的终端 UI 工具)"
echo " 安装后可通过 clashctl tui 进入交互式控制台"
echo
printf " 是否安装 gum?[Y/n] "
local answer
read -r answer 2>/dev/null || answer=""
case "${answer:-Y}" in
[Yy]|[Yy][Ee][Ss]|"")
echo
install_gum || true
;;
*)
echo
echo "⏭️ 已跳过 TUI 安装,后续可手动安装 gum"
echo " 见 https://github.com/charmbracelet/gum#installation"
;;
esac
}

print_install_summary() {
local clashctl_file
local kernel_text project_path arch_text install_actor install_scope_text
Expand Down
139 changes: 113 additions & 26 deletions scripts/core/proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@ proxy_group_exists() {

[ -n "${group:-}" ] || return 1

[ "$(proxy_groups_json | "$(yq_bin)" -p=json eval ".proxies | has(\"$group\")" - 2>/dev/null)" = "true" ]
# 用 to_entries + env(GROUP) 避免 emoji key 触发 yq lexer 错误
[ "$(GROUP="$group" proxy_groups_json | GROUP="$group" "$(yq_bin)" -p=json eval '.proxies | to_entries | .[] | select(.key == env(GROUP)) | "true"' - 2>/dev/null | head -1)" = "true" ]
}

proxy_group_type() {
Expand All @@ -275,7 +276,10 @@ proxy_group_type() {
[ -n "${group:-}" ] || die "策略组名称不能为空"
proxy_group_exists "$group" || die "策略组不存在:$group"

proxy_groups_json | "$(yq_bin)" -p=json eval ".proxies.\"$group\".type // \"\"" - 2>/dev/null
GROUP="$group" proxy_groups_json \
| GROUP="$group" "$(yq_bin)" -p=json eval \
'.proxies | to_entries | .[] | select(.key == env(GROUP)) | .value.type // ""' \
- 2>/dev/null
}

proxy_group_type_key() {
Expand Down Expand Up @@ -378,8 +382,10 @@ proxy_group_current() {
[ -n "${group:-}" ] || die "策略组名称不能为空"
proxy_group_exists "$group" || die "策略组不存在:$group"

proxy_groups_json \
| "$(yq_bin)" -p=json eval ".proxies.\"$group\".now // \"\"" - 2>/dev/null
GROUP="$group" proxy_groups_json \
| GROUP="$group" "$(yq_bin)" -p=json eval \
'.proxies | to_entries | .[] | select(.key == env(GROUP)) | .value.now // ""' \
- 2>/dev/null
}

proxy_group_nodes() {
Expand All @@ -388,8 +394,10 @@ proxy_group_nodes() {
[ -n "${group:-}" ] || die "策略组名称不能为空"
proxy_group_exists "$group" || die "策略组不存在:$group"

proxy_groups_json \
| "$(yq_bin)" -p=json eval ".proxies.\"$group\".all[] // \"\"" - 2>/dev/null
GROUP="$group" proxy_groups_json \
| GROUP="$group" "$(yq_bin)" -p=json eval \
'.proxies | to_entries | .[] | select(.key == env(GROUP)) | .value.all[] // ""' \
- 2>/dev/null
}

proxy_node_is_descriptive_entry() {
Expand Down Expand Up @@ -479,24 +487,14 @@ proxy_group_supports_manual_pick() {
[ "$type_key" = "selector" ] || return 1

has_now="$(
proxy_groups_json \
| "$(yq_bin)" -p=json eval ".proxies.\"$group\".now != null" - 2>/dev/null \
| head -n 1
GROUP="$group" proxy_groups_json | GROUP="$group" "$(yq_bin)" -p=json eval '.proxies | to_entries | .[] | select(.key == env(GROUP)) | .value.now != null' - 2>/dev/null | head -n 1
)"
[ "${has_now:-false}" = "true" ] || return 1

proxy_group_has_selectable_candidates "$group"
}

proxy_group_display_list() {
local group

while IFS= read -r group; do
[ -n "${group:-}" ] || continue
proxy_group_has_selectable_candidates "$group" || continue
echo "$group"
done < <(proxy_group_list)
}
# NOTE: 已由文件底部的高性能版本替代,此处保留仅供参考

proxy_group_manual_list() {
local group
Expand Down Expand Up @@ -833,13 +831,17 @@ proxy_group_current_display() {
}

proxy_group_display_list() {
local group

while IFS= read -r group; do
[ -n "${group:-}" ] || continue
proxy_group_can_show_candidates "$group" || continue
echo "$group"
done < <(proxy_group_list)
# 单次 API 调用,在 yq 内完成策略组类型过滤,避免 N+1 次请求导致策略组丢失
proxy_groups_json \
| "$(yq_bin)" -p=json eval '
.proxies | to_entries | .[] |
select(.value.all != null) |
select(
(.value.type // "" | downcase | sub("[-_ ]+"; "")) as $t |
($t == "selector" or $t == "urltest" or $t == "fallback" or $t == "loadbalance")
) |
.key
' - 2>/dev/null
}

proxy_group_select() {
Expand All @@ -848,6 +850,7 @@ proxy_group_select() {
local base secret
local code response_file response_body
local available_node found
local group_enc

[ -n "${group:-}" ] || die "策略组名称不能为空"
[ -n "${node:-}" ] || die "节点名称不能为空"
Expand All @@ -871,13 +874,14 @@ proxy_group_select() {

base="$(controller_api_base)"
secret="$(controller_secret)"
group_enc="$(proxy_node_url_encode "$group")"
response_file="$(mktemp)"
code="$(
curl -sS -o "$response_file" -w "%{http_code}" -X PUT \
-H "Content-Type: application/json" \
${secret:+-H "Authorization: Bearer $secret"} \
--data "{\"name\":\"$node\"}" \
"$base/proxies/$group"
"$base/proxies/$group_enc"
)"

if [ "${code:-000}" -lt 200 ] || [ "${code:-000}" -ge 300 ]; then
Expand Down Expand Up @@ -930,3 +934,86 @@ print_proxy_groups_summary() {
fi
done < <(proxy_group_list)
}

# ─── 代理模式管理 ──────────────────────────────────────────
clash_mode_get() {
controller_curl GET "/configs" 2>/dev/null \
| "$(yq_bin)" -p=json eval '.mode // "rule"' - 2>/dev/null \
| head -n 1
}

clash_mode_set() {
local mode="$1"
case "$mode" in
global|rule|direct) ;;
*) die "不支持的代理模式:$mode(只允许 global / rule / direct)" ;;
esac
controller_curl PATCH "/configs" "{\"mode\":\"$mode\"}" >/dev/null
}

# ─── 节点延迟 ──────────────────────────────────────────────
proxy_node_url_encode() {
printf '%s' "$1" | sed 's| |%20|g; s|#|%23|g; s|&|%26|g; s|+|%2B|g; s|?|%3F|g'
}

proxy_node_test_delay() {
local node="$1"
local url="${2:-http://www.gstatic.com/generate_204}"
local timeout_ms="${3:-3000}"
local encoded_node

[ -n "${node:-}" ] || return 1
encoded_node="$(proxy_node_url_encode "$node")"

controller_curl GET "/proxies/${encoded_node}/delay?timeout=${timeout_ms}&url=${url}" 2>/dev/null \
| "$(yq_bin)" -p=json eval '.delay // 0' - 2>/dev/null \
| head -n 1
}

proxy_group_nodes_delay_map() {
# 单次 API 调用,输出每行格式:nodename\tdelayms(0 表示无历史记录)
# 使用 tab 分隔,避免节点名含 | 导致解析错误
local group="$1"
[ -n "${group:-}" ] || return 1

GROUP="$group" proxy_groups_json 2>/dev/null \
| GROUP="$group" "$(yq_bin)" -p=json eval '
. as $root |
(.proxies | to_entries | .[] | select(.key == env(GROUP)) | .value.all // []) | .[] |
. as $n |
$n + "\t" + (
($root.proxies[$n].history | select(length > 0) | .[-1].delay // 0) // 0 |
tostring
)
' - 2>/dev/null
}

# ─── 活跃连接 ──────────────────────────────────────────────
connections_json() {
controller_curl GET "/connections"
}

connections_count() {
connections_json 2>/dev/null \
| "$(yq_bin)" -p=json eval '.connections | length' - 2>/dev/null \
| head -n 1 \
|| echo "0"
}

connections_format_rows() {
local limit="${1:-100}"
connections_json 2>/dev/null \
| "$(yq_bin)" -p=json eval "
.connections[0:${limit}][] |
[
((.metadata.host // .metadata.destinationIP // \"-\") | .[0:36]),
((.metadata.type // .metadata.network // \"-\")),
((.chains // []) | reverse | join(\"→\") | .[0:28]),
(.rule // \"-\"),
(
(((.download // 0) / 1024 | tostring | split(".")[0]) + \"K↓\") + \" \" +
(((.upload // 0) / 1024 | tostring | split(".")[0]) + \"K↑\")
)
] | join(\"\t\")
" - 2>/dev/null
}
Loading