clash-controller 0.2.5__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {clash_controller-0.2.5 → clash_controller-0.2.7}/PKG-INFO +23 -4
  2. {clash_controller-0.2.5 → clash_controller-0.2.7}/README.md +22 -3
  3. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/__init__.py +1 -1
  4. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/api.py +170 -31
  5. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/app.tcss +29 -0
  6. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/config.py +8 -4
  7. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/deploy.py +50 -13
  8. clash_controller-0.2.7/clash_controller/elevated_helper.py +36 -0
  9. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/main.py +9 -0
  10. clash_controller-0.2.7/clash_controller/platform_utils.py +631 -0
  11. clash_controller-0.2.7/clash_controller/proxies.py +238 -0
  12. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/config.py +39 -4
  13. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/home.py +10 -5
  14. clash_controller-0.2.7/clash_controller/screens/nodes.py +617 -0
  15. clash_controller-0.2.7/clash_controller/screens/overview.py +565 -0
  16. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/profiles.py +15 -4
  17. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/ssh.py +75 -29
  18. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/widgets.py +9 -2
  19. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/PKG-INFO +23 -4
  20. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/SOURCES.txt +8 -0
  21. {clash_controller-0.2.5 → clash_controller-0.2.7}/pyproject.toml +1 -1
  22. clash_controller-0.2.5/clash_controller/screens/overview.py +0 -234
  23. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/__main__.py +0 -0
  24. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/app.py +0 -0
  25. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/__init__.py +0 -0
  26. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/logs.py +0 -0
  27. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/screens/settings.py +0 -0
  28. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller/ui.py +0 -0
  29. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/dependency_links.txt +0 -0
  30. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/entry_points.txt +0 -0
  31. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/requires.txt +0 -0
  32. {clash_controller-0.2.5 → clash_controller-0.2.7}/clash_controller.egg-info/top_level.txt +0 -0
  33. {clash_controller-0.2.5 → clash_controller-0.2.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clash_controller
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: A Textual TUI for managing Clash proxy instances
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -38,7 +38,8 @@ Requires-Dist: pytest-asyncio; extra == "dev"
38
38
  - 支持 HTTP 与 Unix Domain Socket 连接。
39
39
  - 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
40
40
  - **实时监控面板**:
41
- - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
41
+ - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量;连接表支持关键字(地址 / 规则 / 节点 / 进程)、节点、隐藏 DIRECT 筛选,以及按默认(API 原始顺序)、上/下载速度、流量、时长、地址、规则、节点排序(可切换升/降序)。
42
+ - **节点管理**:按策略组浏览组内成员(节点 / 嵌套组 / DIRECT / REJECT),显示服务端记录的最新延迟、失败与未测状态;支持单节点测速、整组测速、在组内手动指定当前节点,以及对 URLTest / 故障转移组恢复自动(清除固定选择)。provider 节点测速自动走 provider 作用域接口。
42
43
  - **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
43
44
  - **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
44
45
 
@@ -51,9 +52,19 @@ Requires-Dist: pytest-asyncio; extra == "dev"
51
52
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
52
53
  | | `Ctrl+R` | 刷新列表 |
53
54
  | | `Esc` / `Ctrl+C` | 退出程序 |
54
- | **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
55
+ | **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达概览 / 节点 / 配置管理 / 设置 / 切换端点 |
55
56
  | | `↑` / `↓` · `回车` | 移动光标 · 确认 |
56
- | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
57
+ | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `5`) |
58
+ | **概览** | `↑` / `↓` / `PgUp` / `PgDn` | 滚动连接表 |
59
+ | | `/` | 聚焦筛选输入框(地址 / 规则 / 节点 / 进程) |
60
+ | | `Tab` / `Shift+Tab` | 在筛选框、节点 / 排序下拉、隐藏 DIRECT 开关间轮切 |
61
+ | | `Ctrl+R` | 刷新(焦点在输入框时让位给文本编辑) |
62
+ | **节点** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 在组内指定所选节点 |
63
+ | | `Ctrl+T` | 测所选节点延迟 |
64
+ | | `Ctrl+G` | 测整组延迟(URLTest / 故障转移组会清除固定选择,先确认) |
65
+ | | `Ctrl+U` | 恢复自动(清除 URLTest / 故障转移组的固定选择) |
66
+ | | `/` | 聚焦节点名称筛选框 |
67
+ | | `Ctrl+R` | 刷新策略组与延迟 |
57
68
  | **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
58
69
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
59
70
  | | `Ctrl+R` | 刷新 |
@@ -85,6 +96,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
85
96
  clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
86
97
  ```
87
98
 
99
+ ## Windows 说明
100
+
101
+ - Clash 配置目录默认从 `%USERPROFILE%\.config\clash` / `%USERPROFILE%\.config\mihomo` 探测,也可在端点里手动填写(支持 `~`、`%VAR%`)。
102
+ - 若配置目录位于 `Program Files`、`ProgramData` 等受保护位置,部署时会弹出 UAC 请求管理员写入;请在端点设置中保持“管理员写入 (UAC)”开启,并在弹出的对话框中点击“是”。提权后由程序自身的管理员进程完成写入(不借助 PowerShell,避免被安全软件当成攻击行为掐掉)。部署界面会显示当前阶段(请求授权 / 已授权正在写入),详情同时写入“应用日志”页。
103
+ - 写入是否成功以 `config.yaml` 的实际内容为准:只要目标文件已替换为拉取到的配置就判定成功,不会因提权进程未回写确认文件而误报“等待 UAC 超时”。替换走系统 `ReplaceFileW`,会保留 `config.yaml` 原有的访问权限(ACL),原配置备份为同目录的 `config.yaml.bak`。
104
+ - 如果当前进程已经是管理员权限、或账户不属于管理员组,程序不会白弹 UAC,而是直接说明真正的原因(只读属性 / ACL 拒绝 / 文件被占用,或建议把配置目录放到用户目录)。
105
+ - 订阅与本地配置会按 UTF-8 / GB18030 / 带 BOM 的 UTF-16 自动识别,并统一以 UTF-8(无 BOM、LF 换行)写入,不会再把中文变成乱码;控制 API 响应体同样按字节解码,中文节点名不受 `Content-Type` 缺 charset 影响。
106
+
88
107
  ## 开发者安装
89
108
 
90
109
  ```bash
@@ -11,7 +11,8 @@
11
11
  - 支持 HTTP 与 Unix Domain Socket 连接。
12
12
  - 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
13
13
  - **实时监控面板**:
14
- - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
14
+ - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量;连接表支持关键字(地址 / 规则 / 节点 / 进程)、节点、隐藏 DIRECT 筛选,以及按默认(API 原始顺序)、上/下载速度、流量、时长、地址、规则、节点排序(可切换升/降序)。
15
+ - **节点管理**:按策略组浏览组内成员(节点 / 嵌套组 / DIRECT / REJECT),显示服务端记录的最新延迟、失败与未测状态;支持单节点测速、整组测速、在组内手动指定当前节点,以及对 URLTest / 故障转移组恢复自动(清除固定选择)。provider 节点测速自动走 provider 作用域接口。
15
16
  - **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
16
17
  - **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
17
18
 
@@ -24,9 +25,19 @@
24
25
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
25
26
  | | `Ctrl+R` | 刷新列表 |
26
27
  | | `Esc` / `Ctrl+C` | 退出程序 |
27
- | **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
28
+ | **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达概览 / 节点 / 配置管理 / 设置 / 切换端点 |
28
29
  | | `↑` / `↓` · `回车` | 移动光标 · 确认 |
29
- | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
30
+ | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `5`) |
31
+ | **概览** | `↑` / `↓` / `PgUp` / `PgDn` | 滚动连接表 |
32
+ | | `/` | 聚焦筛选输入框(地址 / 规则 / 节点 / 进程) |
33
+ | | `Tab` / `Shift+Tab` | 在筛选框、节点 / 排序下拉、隐藏 DIRECT 开关间轮切 |
34
+ | | `Ctrl+R` | 刷新(焦点在输入框时让位给文本编辑) |
35
+ | **节点** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 在组内指定所选节点 |
36
+ | | `Ctrl+T` | 测所选节点延迟 |
37
+ | | `Ctrl+G` | 测整组延迟(URLTest / 故障转移组会清除固定选择,先确认) |
38
+ | | `Ctrl+U` | 恢复自动(清除 URLTest / 故障转移组的固定选择) |
39
+ | | `/` | 聚焦节点名称筛选框 |
40
+ | | `Ctrl+R` | 刷新策略组与延迟 |
30
41
  | **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
31
42
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
32
43
  | | `Ctrl+R` | 刷新 |
@@ -58,6 +69,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
58
69
  clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
59
70
  ```
60
71
 
72
+ ## Windows 说明
73
+
74
+ - Clash 配置目录默认从 `%USERPROFILE%\.config\clash` / `%USERPROFILE%\.config\mihomo` 探测,也可在端点里手动填写(支持 `~`、`%VAR%`)。
75
+ - 若配置目录位于 `Program Files`、`ProgramData` 等受保护位置,部署时会弹出 UAC 请求管理员写入;请在端点设置中保持“管理员写入 (UAC)”开启,并在弹出的对话框中点击“是”。提权后由程序自身的管理员进程完成写入(不借助 PowerShell,避免被安全软件当成攻击行为掐掉)。部署界面会显示当前阶段(请求授权 / 已授权正在写入),详情同时写入“应用日志”页。
76
+ - 写入是否成功以 `config.yaml` 的实际内容为准:只要目标文件已替换为拉取到的配置就判定成功,不会因提权进程未回写确认文件而误报“等待 UAC 超时”。替换走系统 `ReplaceFileW`,会保留 `config.yaml` 原有的访问权限(ACL),原配置备份为同目录的 `config.yaml.bak`。
77
+ - 如果当前进程已经是管理员权限、或账户不属于管理员组,程序不会白弹 UAC,而是直接说明真正的原因(只读属性 / ACL 拒绝 / 文件被占用,或建议把配置目录放到用户目录)。
78
+ - 订阅与本地配置会按 UTF-8 / GB18030 / 带 BOM 的 UTF-16 自动识别,并统一以 UTF-8(无 BOM、LF 换行)写入,不会再把中文变成乱码;控制 API 响应体同样按字节解码,中文节点名不受 `Content-Type` 缺 charset 影响。
79
+
61
80
  ## 开发者安装
62
81
 
63
82
  ```bash
@@ -2,4 +2,4 @@
2
2
  Clash Controller - A Textual TUI for managing Clash proxy instances.
3
3
  """
4
4
 
5
- __version__ = "0.2.5"
5
+ __version__ = "0.2.7"
@@ -1,12 +1,39 @@
1
+ import json
1
2
  from urllib.parse import quote
2
3
 
3
4
  import requests
4
5
 
6
+ BODY_ENCODINGS = ("utf-8-sig", "gb18030")
7
+
5
8
 
6
9
  def _noop(_message: str) -> None:
7
10
  pass
8
11
 
9
12
 
13
+ def _decode_body(response) -> str:
14
+ """按字节显式解码响应体。
15
+
16
+ 不要直接用 ``response.text``:中间层(反向代理 / CDN)可能把 Content-Type 改成
17
+ 没有 charset 的 ``text/*``,requests 会强制按 ISO-8859-1 解码,中文节点名直接乱码。
18
+ """
19
+ data = response.content or b""
20
+ for encoding in BODY_ENCODINGS:
21
+ try:
22
+ return data.decode(encoding)
23
+ except UnicodeDecodeError:
24
+ continue
25
+ return data.decode("utf-8", errors="replace")
26
+
27
+
28
+ def _parse_body(response):
29
+ """解析响应体:优先按 JSON,失败则退回解码后的文本。"""
30
+ text = _decode_body(response)
31
+ try:
32
+ return json.loads(text)
33
+ except ValueError:
34
+ return text
35
+
36
+
10
37
  class ClashAPI:
11
38
  # Special sentinel returned when the request was sent but the connection
12
39
  # was interrupted (server closed connection / connection reset) and the
@@ -54,7 +81,10 @@ class ClashAPI:
54
81
  except Exception:
55
82
  return False
56
83
 
57
- def _request(self, method, endpoint, params=None, json_data=None, stream=False):
84
+ def _request(
85
+ self, method, endpoint, params=None, json_data=None, stream=False,
86
+ timeout: float | None = None, retry: bool = True,
87
+ ):
58
88
  """Helper method to make requests to the API.
59
89
 
60
90
  Behavior changes:
@@ -62,8 +92,10 @@ class ClashAPI:
62
92
  - Treats PUT /configs ReadTimeout conservatively as success (server-side reload may have completed).
63
93
  - For streaming requests (stream=True) returns the raw response object so callers can iterate.
64
94
  - When debug=True, emits basic request/response information via the log callback.
95
+ - ``timeout``(秒)覆盖本次请求超时;``retry=False`` 时 ReadTimeout 不重试(测速不能重复打内核)。
65
96
  """
66
97
  url = f"{self.base_url}{endpoint}"
98
+ effective_timeout = self.timeout if timeout is None else timeout
67
99
 
68
100
  def do_request(timeout_val):
69
101
  return self.session.request(
@@ -79,11 +111,11 @@ class ClashAPI:
79
111
  try:
80
112
  if self.debug:
81
113
  try:
82
- self._log(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={self.timeout}")
114
+ self._log(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={effective_timeout}")
83
115
  except Exception:
84
116
  pass
85
117
 
86
- response = do_request(self.timeout)
118
+ response = do_request(effective_timeout)
87
119
 
88
120
  # If caller requested streaming, return the raw response so the caller can iterate
89
121
  if stream:
@@ -100,13 +132,10 @@ class ClashAPI:
100
132
  return {"status": "success", "status_code": response.status_code}, None
101
133
 
102
134
  # Try JSON first, fall back to text
103
- try:
104
- parsed = response.json()
105
- except Exception:
106
- parsed = response.text
135
+ parsed = _parse_body(response)
107
136
 
108
137
  if self.debug:
109
- raw = (response.text or '')[:4096]
138
+ raw = _decode_body(response)[:4096]
110
139
  self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
111
140
 
112
141
  # If the parsed result is a dict, inject status_code for caller convenience
@@ -116,6 +145,11 @@ class ClashAPI:
116
145
  return {"body": parsed, "status_code": response.status_code}, None
117
146
 
118
147
  except requests.exceptions.ReadTimeout as e:
148
+ # 测速等场景不能重试(会重复打内核),由调用方显式关闭。
149
+ if not retry:
150
+ if self.debug:
151
+ self._log("[DEBUG] ReadTimeout occurred, retry disabled")
152
+ return None, "请求超时"
119
153
  # Retry once with an increased timeout. If retry fails due to
120
154
  # connection problems and the API is local, return the sentinel so
121
155
  # caller can ask user to verify the outcome manually.
@@ -137,9 +171,13 @@ class ClashAPI:
137
171
  return {"status": "success"}, None
138
172
 
139
173
  if self.debug:
140
- raw = response.text[:4096]
174
+ raw = _decode_body(response)[:4096]
141
175
  self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
142
- return response.json(), None
176
+ parsed = _parse_body(response)
177
+ if isinstance(parsed, dict):
178
+ parsed.setdefault('status_code', response.status_code)
179
+ return parsed, None
180
+ return {"body": parsed, "status_code": response.status_code}, None
143
181
 
144
182
  except requests.exceptions.RequestException as exc:
145
183
  if self.debug:
@@ -172,16 +210,13 @@ class ClashAPI:
172
210
  resp = getattr(e, 'response', None)
173
211
  if resp is not None:
174
212
  try:
175
- body = resp.json()
213
+ body = _parse_body(resp)
176
214
  except Exception:
177
- try:
178
- body = resp.text
179
- except Exception:
180
- body = ''
215
+ body = ''
181
216
 
182
217
  if self.debug:
183
218
  try:
184
- self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp.text or '')[:4096]}")
219
+ self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={_decode_body(resp)[:4096]}")
185
220
  except Exception:
186
221
  self._log(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
187
222
 
@@ -267,16 +302,24 @@ class ClashAPI:
267
302
 
268
303
  def get_proxy(self, name: str):
269
304
  """Get a specific proxy or group's information."""
270
- return self._request('GET', f'/proxies/{quote(name)}')
305
+ return self._request('GET', f'/proxies/{quote(name, safe="")}')
271
306
 
272
307
  def select_proxy_in_group(self, group_name: str, proxy_name: str):
273
308
  """Select a proxy for a specific group."""
274
- return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
309
+ return self._request('PUT', f'/proxies/{quote(group_name, safe="")}', json_data={"name": proxy_name})
275
310
 
276
- def test_proxy_delay(self, name: str, url: str, timeout: int):
277
- """Test a proxy's delay."""
311
+ def test_proxy_delay(self, name: str, url: str, timeout: int = 5000, expected=None):
312
+ """Test a proxy's delay (timeout 单位为毫秒,内核限制 <= 32767)。"""
278
313
  params = {'url': url, 'timeout': str(timeout)}
279
- return self._request('GET', f'/proxies/{quote(name)}/delay', params=params)
314
+ if expected:
315
+ params['expected'] = str(expected)
316
+ return self._request(
317
+ 'GET',
318
+ f'/proxies/{quote(name, safe="")}/delay',
319
+ params=params,
320
+ timeout=timeout / 1000 + 10,
321
+ retry=False,
322
+ )
280
323
 
281
324
  def get_groups(self):
282
325
  """Get policy groups."""
@@ -284,16 +327,31 @@ class ClashAPI:
284
327
 
285
328
  def get_group(self, name: str):
286
329
  """Get a specific policy group."""
287
- return self._request('GET', f'/group/{quote(name)}')
330
+ return self._request('GET', f'/group/{quote(name, safe="")}')
288
331
 
289
332
  def reset_auto_group_selection(self, name: str):
290
- """Reset the fixed selection of an auto policy group."""
291
- return self._request('DELETE', f'/group/{quote(name)}')
333
+ """Reset the fixed selection of an auto policy group.
292
334
 
293
- def test_group_delay(self, name: str, url: str, timeout: int):
294
- """Test the delay of all proxies in a group."""
335
+ 内核没有 ``DELETE /group/{name}``;清除 URLTest / Fallback 的 fixed 走
336
+ ``DELETE /proxies/{name}``(仅 SelectAble 且非 Selector 的组返回 204)。
337
+ """
338
+ return self._request('DELETE', f'/proxies/{quote(name, safe="")}')
339
+
340
+ def test_group_delay(self, name: str, url: str, timeout: int = 5000, expected=None):
341
+ """Test the delay of all proxies in a group.
342
+
343
+ 注意:该端点会先清除 URLTest / Fallback 的 fixed,再并发测试成员。
344
+ """
295
345
  params = {'url': url, 'timeout': str(timeout)}
296
- return self._request('GET', f'/group/{quote(name)}/delay', params=params)
346
+ if expected:
347
+ params['expected'] = str(expected)
348
+ return self._request(
349
+ 'GET',
350
+ f'/group/{quote(name, safe="")}/delay',
351
+ params=params,
352
+ timeout=timeout / 1000 + 10,
353
+ retry=False,
354
+ )
297
355
 
298
356
  # === Providers ===
299
357
  def get_proxy_providers(self):
@@ -302,15 +360,32 @@ class ClashAPI:
302
360
 
303
361
  def get_proxy_provider(self, name: str):
304
362
  """Get a specific proxy provider."""
305
- return self._request('GET', f'/providers/proxies/{quote(name)}')
363
+ return self._request('GET', f'/providers/proxies/{quote(name, safe="")}')
306
364
 
307
365
  def update_proxy_provider(self, name: str):
308
366
  """Update a proxy provider."""
309
- return self._request('PUT', f'/providers/proxies/{quote(name)}')
367
+ return self._request('PUT', f'/providers/proxies/{quote(name, safe="")}')
310
368
 
311
369
  def healthcheck_proxy_provider(self, name: str):
312
370
  """Trigger a health check for a proxy provider."""
313
- return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
371
+ return self._request('GET', f'/providers/proxies/{quote(name, safe="")}/healthcheck')
372
+
373
+ def test_provider_proxy_delay(self, provider: str, node: str, url: str, timeout: int = 5000, expected=None):
374
+ """Test a single provider node's delay.
375
+
376
+ provider 节点不在 ``/proxies`` 顶层字典里,单测必须走 provider 作用域
377
+ (``/providers/proxies/{provider}/{node}/healthcheck``),否则 404。
378
+ """
379
+ params = {'url': url, 'timeout': str(timeout)}
380
+ if expected:
381
+ params['expected'] = str(expected)
382
+ return self._request(
383
+ 'GET',
384
+ f'/providers/proxies/{quote(provider, safe="")}/{quote(node, safe="")}/healthcheck',
385
+ params=params,
386
+ timeout=timeout / 1000 + 10,
387
+ retry=False,
388
+ )
314
389
 
315
390
  def get_rule_providers(self):
316
391
  """Get all rule providers."""
@@ -318,7 +393,7 @@ class ClashAPI:
318
393
 
319
394
  def update_rule_provider(self, name: str):
320
395
  """Update a rule provider."""
321
- return self._request('PUT', f'/providers/rules/{quote(name)}')
396
+ return self._request('PUT', f'/providers/rules/{quote(name, safe="")}')
322
397
 
323
398
  def update_all_providers(self):
324
399
  """更新所有代理合集 (proxy-providers) 与规则合集 (rule-providers)。
@@ -476,6 +551,70 @@ def interpret_api_result(action_desc: str, result, error) -> tuple[str, str]:
476
551
  return (f"{action_desc}失败:{error}", "err")
477
552
 
478
553
 
554
+ DELAY_OK = "ok"
555
+ DELAY_TIMEOUT = "timeout"
556
+ DELAY_ERROR = "error"
557
+
558
+
559
+ def _delay_error_kind(error) -> str | None:
560
+ """把传输层错误归一化为 timeout / error;无错误返回 None。"""
561
+ if error == ClashAPI.SENT_BUT_DISCONNECTED:
562
+ return DELAY_TIMEOUT
563
+ if not error:
564
+ return None
565
+ low = str(error).lower()
566
+ if "timeout" in low or "timed out" in low or "超时" in low:
567
+ return DELAY_TIMEOUT
568
+ return DELAY_ERROR
569
+
570
+
571
+ def interpret_delay_result(result, error) -> tuple[str, int | None]:
572
+ """解释单节点测速结果:``(DELAY_OK|DELAY_TIMEOUT|DELAY_ERROR, 毫秒或 None)``。
573
+
574
+ 内核把 504 当正常响应体返回(ClashAPI 不抛异常),因此必须读 ``status_code``。
575
+ """
576
+ kind = _delay_error_kind(error)
577
+ if kind is not None:
578
+ return kind, None
579
+ if not isinstance(result, dict):
580
+ return DELAY_ERROR, None
581
+ status_code = result.get("status_code")
582
+ if status_code == 504:
583
+ return DELAY_TIMEOUT, None
584
+ if status_code in (400, 404, 503):
585
+ return DELAY_ERROR, None
586
+ if status_code != 200:
587
+ return DELAY_ERROR, None
588
+ delay = result.get("delay")
589
+ if isinstance(delay, bool) or not isinstance(delay, int) or delay <= 0:
590
+ return DELAY_ERROR, None
591
+ return DELAY_OK, delay
592
+
593
+
594
+ def interpret_group_delay_result(result, error) -> tuple[str, dict[str, int]]:
595
+ """解释整组测速结果:``(状态, {节点名: 毫秒})``;只有整数项被保留。"""
596
+ kind = _delay_error_kind(error)
597
+ if kind is not None:
598
+ return kind, {}
599
+ if not isinstance(result, dict):
600
+ return DELAY_ERROR, {}
601
+ status_code = result.get("status_code")
602
+ if status_code == 504:
603
+ return DELAY_TIMEOUT, {}
604
+ if status_code in (400, 404, 503):
605
+ return DELAY_ERROR, {}
606
+ if status_code != 200:
607
+ return DELAY_ERROR, {}
608
+ mapping: dict[str, int] = {}
609
+ for key, value in result.items():
610
+ if key == "status_code":
611
+ continue
612
+ if isinstance(value, bool) or not isinstance(value, int):
613
+ continue
614
+ mapping[str(key)] = value
615
+ return DELAY_OK, mapping
616
+
617
+
479
618
  def _brief(raw, limit: int = 120) -> str:
480
619
  """把任意响应体压成一行短文本,供提示栏/通知展示。"""
481
620
  if raw is None:
@@ -112,6 +112,35 @@ Button:disabled {
112
112
  margin-left: 2;
113
113
  }
114
114
 
115
+ /* 概览页连接筛选:Select 显式定宽(否则内部 1fr 标签会吃掉整行),Switch 压成单行 */
116
+ #cn-node {
117
+ width: 20;
118
+ }
119
+
120
+ #cn-sort {
121
+ width: 14;
122
+ }
123
+
124
+ /* 节点页:策略组 / 排序下拉显式定宽(Select 内部 1fr 标签会吃掉整行) */
125
+ #nd-group {
126
+ width: 24;
127
+ }
128
+
129
+ #nd-sort {
130
+ width: 12;
131
+ }
132
+
133
+ .filter-row Switch {
134
+ height: 1;
135
+ border: none;
136
+ padding: 0 1;
137
+ margin-left: 1;
138
+ }
139
+
140
+ .filter-row Switch:focus {
141
+ background-tint: $foreground 8%;
142
+ }
143
+
115
144
  /* ---------- 表格 ---------- */
116
145
  .tbl {
117
146
  height: 1fr;
@@ -31,9 +31,13 @@ def _load_json(path: Path, log: Callable[[str], None]) -> list:
31
31
  if not path.exists():
32
32
  return []
33
33
  try:
34
- with open(path, encoding="utf-8") as f:
35
- data = json.load(f)
36
- except (json.JSONDecodeError, OSError) as e:
34
+ raw = path.read_bytes()
35
+ if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
36
+ text = raw.decode("utf-16")
37
+ else:
38
+ text = raw.decode("utf-8-sig")
39
+ data = json.loads(text)
40
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e:
37
41
  log(f"无法读取或解析配置文件 {path}: {e}")
38
42
  return []
39
43
  return data if isinstance(data, list) else []
@@ -41,7 +45,7 @@ def _load_json(path: Path, log: Callable[[str], None]) -> list:
41
45
 
42
46
  def _save_json(path: Path, data: list) -> Path:
43
47
  path.parent.mkdir(parents=True, exist_ok=True)
44
- with open(path, "w", encoding="utf-8") as f:
48
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
45
49
  json.dump(data, f, indent=4, ensure_ascii=False)
46
50
  return path
47
51
 
@@ -8,8 +8,11 @@ from urllib.parse import urlparse
8
8
 
9
9
  import requests
10
10
 
11
+ from .platform_utils import default_local_config_dir, expand_local_path
11
12
  from .ssh import fetch_remote_file_meta_and_content
12
13
 
14
+ CONFIG_ENCODINGS = ("utf-8-sig", "gb18030")
15
+
13
16
 
14
17
  def _noop(_message: str) -> None:
15
18
  pass
@@ -40,25 +43,59 @@ def default_ssh_host(url: str) -> str:
40
43
  return '127.0.0.1'
41
44
 
42
45
 
43
- def get_remote_last_modified(url: str) -> datetime | None:
44
- """读取远端 URL 的 Last-Modified 头,失败或缺失返回 None。"""
46
+ def decode_config_bytes(data: bytes) -> str:
47
+ """把订阅 / 本地配置字节解码为文本。
48
+
49
+ 不能用 ``response.text``:requests 对没有 charset 的 ``text/*`` 会强制按
50
+ ISO-8859-1 解码,中文会变成乱码。这里显式按 UTF-8(去 BOM)→ GB18030
51
+ 的顺序尝试,覆盖国内机场常见的两种编码;带 BOM 的 UTF-16(Windows 记事本
52
+ “Unicode”格式)也能识别。
53
+ """
54
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
55
+ # Windows 记事本“Unicode”保存的是带 BOM 的 UTF-16。
56
+ try:
57
+ return data.decode("utf-16")
58
+ except UnicodeDecodeError:
59
+ pass
60
+ for encoding in CONFIG_ENCODINGS:
61
+ try:
62
+ return data.decode(encoding)
63
+ except UnicodeDecodeError:
64
+ continue
65
+ raise RuntimeError("配置内容不是有效的 UTF-8 / GB18030 文本,无法部署")
66
+
67
+
68
+ def normalize_config_text(text: str) -> str:
69
+ """统一为 LF 换行,避免 Windows 文本模式写出的 CRLF 造成哈希反复不一致。"""
70
+ return text.replace("\r\n", "\n").replace("\r", "\n")
71
+
72
+
73
+ def canonical_config_bytes(data: bytes) -> bytes:
74
+ """把配置规范化为 UTF-8(无 BOM、LF 换行)字节,用于落盘与哈希比对。"""
75
+ return normalize_config_text(decode_config_bytes(data)).encode("utf-8")
76
+
77
+
78
+ def parse_last_modified(value: str | None) -> datetime | None:
79
+ if not value:
80
+ return None
45
81
  try:
46
- response = requests.head(url, timeout=5)
47
- response.raise_for_status()
48
- last_modified = response.headers.get('Last-Modified')
49
- if last_modified:
50
- return parsedate_to_datetime(last_modified)
51
- except requests.exceptions.RequestException:
82
+ return parsedate_to_datetime(value)
83
+ except (TypeError, ValueError, OverflowError):
52
84
  return None
53
- return None
54
85
 
55
86
 
56
87
  def fetch_remote_config(url: str) -> tuple[str, str, datetime | None]:
57
- """拉取远端配置,返回 (text, sha256, last_modified)。失败抛异常。"""
88
+ """拉取远端配置,返回 (规范化文本, sha256, last_modified)。失败抛异常。
89
+
90
+ 直接读取 ``response.content`` 并显式解码,最后统一换行;哈希基于规范化后的
91
+ UTF-8 字节,保证与本地落盘内容可比。Last-Modified 取自本次 GET 响应,避免
92
+ 额外 HEAD 请求(部分订阅服务器不支持 HEAD)。
93
+ """
58
94
  response = requests.get(url, timeout=30)
59
95
  response.raise_for_status()
60
- text = response.text
61
- return text, compute_sha256(text.encode('utf-8')), get_remote_last_modified(url)
96
+ payload = canonical_config_bytes(response.content)
97
+ text = payload.decode('utf-8')
98
+ return text, compute_sha256(payload), parse_last_modified(response.headers.get('Last-Modified'))
62
99
 
63
100
 
64
101
  def read_current_config(
@@ -70,7 +107,7 @@ def read_current_config(
70
107
  if profile.get('endpoint_type', 'local') == 'remote':
71
108
  return fetch_remote_file_meta_and_content(profile, log=log, debug=debug)
72
109
 
73
- config_dir = profile.get('config_directory', '/etc/clash')
110
+ config_dir = expand_local_path(profile.get('config_directory') or default_local_config_dir())
74
111
  config_path = os.path.join(config_dir, 'config.yaml')
75
112
  if not os.path.exists(config_path):
76
113
  raise RuntimeError(f"local config file not found: {config_path}")
@@ -0,0 +1,36 @@
1
+ """Windows 提权写入 helper:由 ``platform_utils`` 通过 UAC 以管理员令牌再启动本包执行。
2
+
3
+ 职责刻意收窄成「读 payload → 原子写目标 → 回写状态」三件事:不读应用配置、不联网、不碰 UI,
4
+ 所以被提权的进程能做的事非常有限,出错也只可能来自这几个路径。
5
+ """
6
+ import argparse
7
+ import sys
8
+
9
+ from .platform_utils import HELPER_FLAG, elevated_apply_payload, write_status
10
+
11
+ EXIT_OK = 0
12
+ EXIT_FAILED = 1
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog=f"clashctl {HELPER_FLAG}",
18
+ description="以管理员权限写入 Clash 配置文件(由 clashctl 内部调用,无需手动执行)",
19
+ )
20
+ parser.add_argument("payload", help="待写入内容所在的临时文件")
21
+ parser.add_argument("target", help="目标 config.yaml 的绝对路径")
22
+ parser.add_argument("backup", help="备份文件路径")
23
+ parser.add_argument("status", help="回写结果的状态文件路径")
24
+ args = parser.parse_args(argv)
25
+
26
+ try:
27
+ elevated_apply_payload(args.payload, args.target, args.backup)
28
+ except BaseException as exc: # noqa: BLE001 # 提权进程必须把任何失败原因带回主进程
29
+ write_status(args.status, f"ERR {type(exc).__name__}: {exc}")
30
+ return EXIT_FAILED
31
+ write_status(args.status, "OK")
32
+ return EXIT_OK
33
+
34
+
35
+ if __name__ == "__main__":
36
+ sys.exit(main())
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from . import __version__
9
9
  from .config import CONFIG_DIR_ENV, DEFAULT_CONFIG_DIR
10
+ from .platform_utils import HELPER_FLAG
10
11
 
11
12
 
12
13
  def _setup_logging() -> None:
@@ -37,6 +38,14 @@ def parse_args():
37
38
 
38
39
 
39
40
  def main() -> int:
41
+ # 提权 helper:打包成 exe 后 sys.executable 就是本程序,UAC 提权重启时由这里分派;
42
+ # pip 安装则直接走 `python -m clash_controller.elevated_helper`,不经过本函数。
43
+ argv = sys.argv[1:]
44
+ if argv[:1] == [HELPER_FLAG]:
45
+ from .elevated_helper import main as elevated_main
46
+
47
+ return elevated_main(argv[1:])
48
+
40
49
  _setup_logging()
41
50
  args = parse_args()
42
51
  os.environ[CONFIG_DIR_ENV] = args.dir