clash-controller 0.0.2__tar.gz → 0.2.0__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 (38) hide show
  1. clash_controller-0.2.0/PKG-INFO +113 -0
  2. clash_controller-0.2.0/README.md +86 -0
  3. clash_controller-0.2.0/clash_controller/__init__.py +5 -0
  4. clash_controller-0.2.0/clash_controller/__main__.py +6 -0
  5. {clash_controller-0.0.2 → clash_controller-0.2.0}/clash_controller/api.py +98 -24
  6. clash_controller-0.2.0/clash_controller/app.py +116 -0
  7. clash_controller-0.2.0/clash_controller/app.tcss +235 -0
  8. clash_controller-0.2.0/clash_controller/config.py +62 -0
  9. clash_controller-0.2.0/clash_controller/deploy.py +96 -0
  10. clash_controller-0.2.0/clash_controller/main.py +51 -0
  11. clash_controller-0.2.0/clash_controller/screens/__init__.py +1 -0
  12. clash_controller-0.2.0/clash_controller/screens/config.py +247 -0
  13. clash_controller-0.2.0/clash_controller/screens/home.py +96 -0
  14. clash_controller-0.2.0/clash_controller/screens/logs.py +29 -0
  15. clash_controller-0.2.0/clash_controller/screens/overview.py +205 -0
  16. clash_controller-0.2.0/clash_controller/screens/profiles.py +410 -0
  17. clash_controller-0.2.0/clash_controller/screens/settings.py +137 -0
  18. clash_controller-0.2.0/clash_controller/ssh.py +216 -0
  19. clash_controller-0.2.0/clash_controller/ui.py +302 -0
  20. clash_controller-0.2.0/clash_controller/widgets.py +520 -0
  21. clash_controller-0.2.0/clash_controller.egg-info/PKG-INFO +113 -0
  22. clash_controller-0.2.0/clash_controller.egg-info/SOURCES.txt +44 -0
  23. clash_controller-0.2.0/clash_controller.egg-info/entry_points.txt +2 -0
  24. clash_controller-0.2.0/clash_controller.egg-info/requires.txt +8 -0
  25. clash_controller-0.2.0/pyproject.toml +63 -0
  26. clash_controller-0.0.2/PKG-INFO +0 -77
  27. clash_controller-0.0.2/README.md +0 -60
  28. clash_controller-0.0.2/clash_controller/__init__.py +0 -6
  29. clash_controller-0.0.2/clash_controller/__main__.py +0 -9
  30. clash_controller-0.0.2/clash_controller/cli.py +0 -814
  31. clash_controller-0.0.2/clash_controller.egg-info/PKG-INFO +0 -77
  32. clash_controller-0.0.2/clash_controller.egg-info/SOURCES.txt +0 -12
  33. clash_controller-0.0.2/clash_controller.egg-info/entry_points.txt +0 -2
  34. clash_controller-0.0.2/clash_controller.egg-info/requires.txt +0 -3
  35. clash_controller-0.0.2/pyproject.toml +0 -31
  36. {clash_controller-0.0.2 → clash_controller-0.2.0}/clash_controller.egg-info/dependency_links.txt +0 -0
  37. {clash_controller-0.0.2 → clash_controller-0.2.0}/clash_controller.egg-info/top_level.txt +0 -0
  38. {clash_controller-0.0.2 → clash_controller-0.2.0}/setup.cfg +0 -0
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: clash_controller
3
+ Version: 0.2.0
4
+ Summary: A Textual TUI for managing Clash proxy instances
5
+ Author-email: Moha-Master <hongkongreporter@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Moha-Master/Clash-Controller
8
+ Project-URL: Repository, https://github.com/Moha-Master/Clash-Controller.git
9
+ Project-URL: GitHub, https://github.com/Moha-Master/Clash-Controller
10
+ Project-URL: Issues, https://github.com/Moha-Master/Clash-Controller/issues
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: requests
21
+ Requires-Dist: requests_unixsocket
22
+ Requires-Dist: paramiko
23
+ Requires-Dist: textual>=8.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=6.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio; extra == "dev"
27
+
28
+ # Clash Controller
29
+
30
+ 一个基于 [Textual](https://github.com/Textualize/textual) 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器,方便在终端中管理和监控一个或多个 `clash` 实例。
31
+
32
+ ## 功能特性
33
+
34
+ - **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,单击数据行即执行,非阻塞异步加载与应用内日志。
35
+ - **多端点管理**:
36
+ - 自动保存连接过的 Clash 端点(地址与密钥)。
37
+ - 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
38
+ - 支持 HTTP 与 Unix Domain Socket 连接。
39
+ - 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
40
+ - **实时监控面板**:
41
+ - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
42
+ - **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
43
+ - **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
44
+
45
+ ## 界面与快捷键
46
+
47
+ | 场景 | 快捷键 | 作用 |
48
+ |---|---|---|
49
+ | **端点选择(根屏)** | `↑` / `↓` · `回车` 或 **单击** | 移动光标 · 连接所选端点 |
50
+ | | `1-9` | 按数字直达连接第 N 个端点 |
51
+ | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
52
+ | | `Ctrl+R` | 刷新列表 |
53
+ | | `Esc` / `Ctrl+C` | 退出程序 |
54
+ | **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
55
+ | | `↑` / `↓` · `回车` | 移动光标 · 确认 |
56
+ | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
57
+ | **配置管理** | `↑` / `↓` · `回车` 或 **单击行** | 移动光标 · 部署所选提供方 |
58
+ | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
59
+ | | `Ctrl+R` | 刷新 |
60
+ | **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
61
+ | | `Tab` / `Shift+Tab` | 表单字段轮切焦点 |
62
+ | | `Esc` / `Ctrl+C` | 返回上一页 |
63
+ | | `Ctrl+Q` | 任意界面直接退出程序 |
64
+ | **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
65
+ | | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
66
+
67
+ > 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
68
+ > 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
69
+ > 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
70
+
71
+ ## 安装
72
+
73
+ ```bash
74
+ pip install clash-controller
75
+ ```
76
+
77
+ 安装后,可以通过以下命令启动程序:
78
+
79
+ ```bash
80
+ clashctl
81
+ ```
82
+
83
+ > 需要 Python 3.10 及以上版本。
84
+
85
+ 可选参数:
86
+
87
+ ```bash
88
+ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-controller)
89
+ clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
90
+ ```
91
+
92
+ ## 开发者安装
93
+
94
+ ```bash
95
+ git clone https://github.com/Moha-Master/clash-controller.git
96
+ cd clash-controller
97
+
98
+ python -m venv venv
99
+ source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
100
+
101
+ pip install -e ".[dev]"
102
+
103
+ python -m clash_controller
104
+ ```
105
+
106
+ ## 要求
107
+
108
+ - Python 3.10+
109
+ - 运行中的 Clash 实例,已开启外部控制 API
110
+
111
+ ## 许可证
112
+
113
+ MIT
@@ -0,0 +1,86 @@
1
+ # Clash Controller
2
+
3
+ 一个基于 [Textual](https://github.com/Textualize/textual) 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器,方便在终端中管理和监控一个或多个 `clash` 实例。
4
+
5
+ ## 功能特性
6
+
7
+ - **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,单击数据行即执行,非阻塞异步加载与应用内日志。
8
+ - **多端点管理**:
9
+ - 自动保存连接过的 Clash 端点(地址与密钥)。
10
+ - 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
11
+ - 支持 HTTP 与 Unix Domain Socket 连接。
12
+ - 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
13
+ - **实时监控面板**:
14
+ - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
15
+ - **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
16
+ - **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
17
+
18
+ ## 界面与快捷键
19
+
20
+ | 场景 | 快捷键 | 作用 |
21
+ |---|---|---|
22
+ | **端点选择(根屏)** | `↑` / `↓` · `回车` 或 **单击** | 移动光标 · 连接所选端点 |
23
+ | | `1-9` | 按数字直达连接第 N 个端点 |
24
+ | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
25
+ | | `Ctrl+R` | 刷新列表 |
26
+ | | `Esc` / `Ctrl+C` | 退出程序 |
27
+ | **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
28
+ | | `↑` / `↓` · `回车` | 移动光标 · 确认 |
29
+ | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
30
+ | **配置管理** | `↑` / `↓` · `回车` 或 **单击行** | 移动光标 · 部署所选提供方 |
31
+ | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
32
+ | | `Ctrl+R` | 刷新 |
33
+ | **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
34
+ | | `Tab` / `Shift+Tab` | 表单字段轮切焦点 |
35
+ | | `Esc` / `Ctrl+C` | 返回上一页 |
36
+ | | `Ctrl+Q` | 任意界面直接退出程序 |
37
+ | **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
38
+ | | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
39
+
40
+ > 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
41
+ > 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
42
+ > 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
43
+
44
+ ## 安装
45
+
46
+ ```bash
47
+ pip install clash-controller
48
+ ```
49
+
50
+ 安装后,可以通过以下命令启动程序:
51
+
52
+ ```bash
53
+ clashctl
54
+ ```
55
+
56
+ > 需要 Python 3.10 及以上版本。
57
+
58
+ 可选参数:
59
+
60
+ ```bash
61
+ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-controller)
62
+ clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
63
+ ```
64
+
65
+ ## 开发者安装
66
+
67
+ ```bash
68
+ git clone https://github.com/Moha-Master/clash-controller.git
69
+ cd clash-controller
70
+
71
+ python -m venv venv
72
+ source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
73
+
74
+ pip install -e ".[dev]"
75
+
76
+ python -m clash_controller
77
+ ```
78
+
79
+ ## 要求
80
+
81
+ - Python 3.10+
82
+ - 运行中的 Clash 实例,已开启外部控制 API
83
+
84
+ ## 许可证
85
+
86
+ MIT
@@ -0,0 +1,5 @@
1
+ """
2
+ Clash Controller - A Textual TUI for managing Clash proxy instances.
3
+ """
4
+
5
+ __version__ = "0.2.0"
@@ -0,0 +1,6 @@
1
+ """Allow running the CLI with `python -m clash_controller`."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -1,12 +1,19 @@
1
- import requests
2
1
  from urllib.parse import quote
3
2
 
3
+ import requests
4
+
5
+
6
+ def _noop(_message: str) -> None:
7
+ pass
8
+
9
+
4
10
  class ClashAPI:
5
11
  # Special sentinel returned when the request was sent but the connection
6
12
  # was interrupted (server closed connection / connection reset) and the
7
13
  # client cannot determine final outcome. Caller should prompt user to verify.
8
14
  SENT_BUT_DISCONNECTED = "SENT_BUT_DISCONNECTED"
9
- def __init__(self, base_url, secret=None, timeout=10, working_directory=None, debug=False):
15
+
16
+ def __init__(self, base_url, secret=None, timeout=10, working_directory=None, debug=False, log=None):
10
17
  """
11
18
  Initializes the Clash API client.
12
19
 
@@ -15,20 +22,24 @@ class ClashAPI:
15
22
  :param secret: The secret for API authentication.
16
23
  :param timeout: Request timeout in seconds.
17
24
  :param working_directory: The working directory of the Clash core, used for config paths.
25
+ :param debug: When True, emit raw request/response info via ``log``.
26
+ :param log: Callable receiving debug strings (defaults to no-op; never prints).
18
27
  """
19
28
  self.base_url = base_url
20
29
  self.timeout = timeout
21
30
  self.headers = {}
22
31
  self.working_directory = working_directory
23
- # When debug is True, the client will print raw request/response info
24
32
  self.debug = bool(debug)
33
+ # Debug output is routed to this callback (never stdout) so it cannot
34
+ # corrupt the TUI.
35
+ self._log = log or _noop
25
36
  if secret:
26
37
  self.headers['Authorization'] = f'Bearer {secret}'
27
38
 
28
39
  if self.base_url.startswith('unix://'):
29
40
  import requests_unixsocket
30
41
  self.session = requests_unixsocket.Session()
31
- encoded_path = quote(self.base_url.lstrip('unix://'), safe='')
42
+ encoded_path = quote(self.base_url.removeprefix('unix://'), safe='')
32
43
  self.base_url = f'http+unix://{encoded_path}'
33
44
  else:
34
45
  self.session = requests.Session()
@@ -50,7 +61,7 @@ class ClashAPI:
50
61
  - Retries once on ReadTimeout with a longer timeout.
51
62
  - Treats PUT /configs ReadTimeout conservatively as success (server-side reload may have completed).
52
63
  - For streaming requests (stream=True) returns the raw response object so callers can iterate.
53
- - When debug=True, prints basic request/response information to stdout.
64
+ - When debug=True, emits basic request/response information via the log callback.
54
65
  """
55
66
  url = f"{self.base_url}{endpoint}"
56
67
 
@@ -68,7 +79,7 @@ class ClashAPI:
68
79
  try:
69
80
  if self.debug:
70
81
  try:
71
- print(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={self.timeout}")
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}")
72
83
  except Exception:
73
84
  pass
74
85
 
@@ -77,7 +88,7 @@ class ClashAPI:
77
88
  # If caller requested streaming, return the raw response so the caller can iterate
78
89
  if stream:
79
90
  if self.debug:
80
- print(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} (streaming)")
91
+ self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} (streaming)")
81
92
  return response, None
82
93
 
83
94
  # For non-stream responses: always return the API-provided body (prefer JSON),
@@ -85,7 +96,7 @@ class ClashAPI:
85
96
  # encodes operation result details in the response body.
86
97
  if response.status_code == 204 or not response.content:
87
98
  if self.debug:
88
- print(f"[DEBUG] Response: status={response.status_code} (no content)")
99
+ self._log(f"[DEBUG] Response: status={response.status_code} (no content)")
89
100
  return {"status": "success", "status_code": response.status_code}, None
90
101
 
91
102
  # Try JSON first, fall back to text
@@ -96,7 +107,7 @@ class ClashAPI:
96
107
 
97
108
  if self.debug:
98
109
  raw = (response.text or '')[:4096]
99
- print(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
110
+ self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
100
111
 
101
112
  # If the parsed result is a dict, inject status_code for caller convenience
102
113
  if isinstance(parsed, dict):
@@ -109,30 +120,33 @@ class ClashAPI:
109
120
  # connection problems and the API is local, return the sentinel so
110
121
  # caller can ask user to verify the outcome manually.
111
122
  try:
112
- longer_timeout = max(self.timeout * 3, 15)
123
+ longer_timeout = 15
113
124
  if self.debug:
114
- print(f"[DEBUG] ReadTimeout occurred, retrying with timeout={longer_timeout}")
125
+ self._log(f"[DEBUG] ReadTimeout occurred, retrying with timeout={longer_timeout}")
115
126
  response = do_request(longer_timeout)
116
127
  response.raise_for_status()
117
128
 
118
129
  if stream:
119
130
  if self.debug:
120
- print(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} (streaming)")
131
+ self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} (streaming)")
121
132
  return response, None
122
133
 
123
134
  if response.status_code == 204 or not response.content:
124
135
  if self.debug:
125
- print(f"[DEBUG] Response after retry: status={response.status_code} (no content)")
136
+ self._log(f"[DEBUG] Response after retry: status={response.status_code} (no content)")
126
137
  return {"status": "success"}, None
127
138
 
128
139
  if self.debug:
129
140
  raw = response.text[:4096]
130
- print(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
141
+ self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
131
142
  return response.json(), None
132
143
 
133
144
  except requests.exceptions.RequestException as exc:
134
145
  if self.debug:
135
- print(f"[DEBUG] ReadTimeout retry failed: {exc}")
146
+ self._log(f"[DEBUG] ReadTimeout retry failed: {exc}")
147
+ # Reload config may legitimately finish server-side while client times out.
148
+ if method.upper() == 'PUT' and endpoint.startswith('/configs'):
149
+ return None, self.SENT_BUT_DISCONNECTED
136
150
  # If this is a local API, we conservatively cannot determine the final
137
151
  # state (server may have applied the operation then closed the connection),
138
152
  # so inform the caller to verify manually.
@@ -144,7 +158,9 @@ class ClashAPI:
144
158
  # Connection errors/remote close — if local, return sentinel so caller
145
159
  # can prompt user to manually verify; otherwise return the error.
146
160
  if self.debug:
147
- print(f"[DEBUG] Connection error: {e}")
161
+ self._log(f"[DEBUG] Connection error: {e}")
162
+ if method.upper() == 'PUT' and endpoint.startswith('/configs'):
163
+ return None, self.SENT_BUT_DISCONNECTED
148
164
  if self._is_local():
149
165
  return None, self.SENT_BUT_DISCONNECTED
150
166
  return None, str(e)
@@ -165,9 +181,9 @@ class ClashAPI:
165
181
 
166
182
  if self.debug:
167
183
  try:
168
- print(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp.text or '')[:4096]}")
184
+ self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp.text or '')[:4096]}")
169
185
  except Exception:
170
- print(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
186
+ self._log(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
171
187
 
172
188
  # Return the response body as the result, do not treat as exception
173
189
  if isinstance(body, dict):
@@ -177,7 +193,7 @@ class ClashAPI:
177
193
 
178
194
  # No response available: transport-level error
179
195
  if self.debug:
180
- print(f"[DEBUG] RequestException without response (transport error): {e}")
196
+ self._log(f"[DEBUG] RequestException without response (transport error): {e}")
181
197
  return None, str(e)
182
198
 
183
199
  # === Real-time Data ===
@@ -218,7 +234,7 @@ class ClashAPI:
218
234
  def reload_configs(self, path="", payload=""):
219
235
  """Reload configuration from path."""
220
236
  return self._request('PUT', '/configs', params={'force': 'true'}, json_data={"path": path, "payload": payload} or {})
221
-
237
+
222
238
  def set_mode(self, mode: str):
223
239
  """Sets the connection mode ('rule', 'global', 'direct')."""
224
240
  return self.update_configs({"mode": mode.lower()})
@@ -252,7 +268,7 @@ class ClashAPI:
252
268
  def get_proxy(self, name: str):
253
269
  """Get a specific proxy or group's information."""
254
270
  return self._request('GET', f'/proxies/{quote(name)}')
255
-
271
+
256
272
  def select_proxy_in_group(self, group_name: str, proxy_name: str):
257
273
  """Select a proxy for a specific group."""
258
274
  return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
@@ -278,7 +294,7 @@ class ClashAPI:
278
294
  """Test the delay of all proxies in a group."""
279
295
  params = {'url': url, 'timeout': str(timeout)}
280
296
  return self._request('GET', f'/group/{quote(name)}/delay', params=params)
281
-
297
+
282
298
  # === Providers ===
283
299
  def get_proxy_providers(self):
284
300
  """Get all proxy providers."""
@@ -295,7 +311,7 @@ class ClashAPI:
295
311
  def healthcheck_proxy_provider(self, name: str):
296
312
  """Trigger a health check for a proxy provider."""
297
313
  return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
298
-
314
+
299
315
  def get_rule_providers(self):
300
316
  """Get all rule providers."""
301
317
  return self._request('GET', '/providers/rules')
@@ -325,4 +341,62 @@ class ClashAPI:
325
341
  # === DNS ===
326
342
  def query_dns(self, name: str, query_type: str = 'A'):
327
343
  """Query DNS."""
328
- return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
344
+ return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
345
+
346
+
347
+ def interpret_api_result(action_desc: str, result, error) -> tuple[str, str]:
348
+ """把 ClashAPI 的 ``(result, error)`` 解释为 ``(message, severity)``。
349
+
350
+ severity ∈ {"ok", "warn", "err"};纯逻辑、无副作用,供界面层展示。
351
+ 保留 ``SENT_BUT_DISCONNECTED`` 的「已发送但断连」语义。
352
+ """
353
+
354
+ def _clean(raw: str) -> str:
355
+ if not raw:
356
+ return ''
357
+ s = raw.strip()
358
+ for prefix in ('update error:', 'error:', 'message:', 'update:'):
359
+ if s.lower().startswith(prefix):
360
+ s = s[len(prefix):].strip()
361
+ break
362
+ if s:
363
+ s = s[0].upper() + s[1:]
364
+ return s
365
+
366
+ if error is None:
367
+ if isinstance(result, dict):
368
+ status_val = str(result.get('status', '')).lower()
369
+ status_code = result.get('status_code')
370
+ if 'message' in result:
371
+ short_msg = _clean(str(result['message']))
372
+ elif 'info' in result:
373
+ short_msg = _clean(str(result['info']))
374
+ elif isinstance(result.get('body'), str):
375
+ short_msg = _clean(result['body'])
376
+ else:
377
+ short_msg = ''
378
+
379
+ success = status_val in ('success', 'ok', 'done', 'true') or (
380
+ isinstance(status_code, int) and status_code in (200, 201, 202, 204)
381
+ )
382
+ if success:
383
+ return (f"已成功{action_desc}。" + (f" 提示:{short_msg}" if short_msg else ""), "ok")
384
+ if short_msg:
385
+ return (f"{action_desc}: {short_msg}", "warn")
386
+ return (f"{action_desc}已完成(请查看详情)。", "ok")
387
+
388
+ text = str(result) if result is not None else ''
389
+ return (f"{action_desc}: {text}" if text else f"已成功{action_desc}。", "ok")
390
+
391
+ if error == ClashAPI.SENT_BUT_DISCONNECTED:
392
+ return (
393
+ f"请求“{action_desc}”已发送,但在读取最终响应前连接中断;"
394
+ "该操作可能已生效,请手动检查 Clash 日志 / 状态确认。",
395
+ "warn",
396
+ )
397
+
398
+ if isinstance(result, dict) and result.get('info'):
399
+ return (f"{action_desc}完成:{str(result.get('info'))[:2000]}", "ok")
400
+
401
+ return (f"{action_desc}失败:{error}", "err")
402
+
@@ -0,0 +1,116 @@
1
+ """Textual 应用入口:全局档案 / 客户端、状态栏、日志与启动流程。"""
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from textual import work
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.widgets import Static
9
+
10
+ from .api import ClashAPI
11
+
12
+
13
+ class StatusBar(Static):
14
+ """底部状态栏:展示最近一次操作结果 / 进行中提示。"""
15
+
16
+
17
+ class ClashControllerApp(App):
18
+ """Clash 管理 TUI。"""
19
+
20
+ CSS_PATH = Path(__file__).parent / "app.tcss"
21
+ TITLE = "Clash Controller"
22
+ ENABLE_COMMAND_PALETTE = False
23
+
24
+ BINDINGS = [
25
+ Binding("ctrl+q", "quit", "退出", priority=True),
26
+ ]
27
+
28
+ def __init__(self, debug: bool = False) -> None:
29
+ super().__init__()
30
+ self.debug_mode = bool(debug)
31
+ self.profile: dict | None = None
32
+ self._api: ClashAPI | None = None
33
+ self.app_logs: list[str] = []
34
+
35
+ # ------------------------------------------------------------ 日志
36
+
37
+ def add_log(self, message: str) -> None:
38
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
39
+ self.app_logs.append(f"[{timestamp}] {message}")
40
+
41
+ # ------------------------------------------------------------ 档案 / 客户端
42
+
43
+ def set_profile(self, profile: dict) -> None:
44
+ self.profile = profile
45
+ self._api = None
46
+
47
+ def clear_profile(self) -> None:
48
+ self.profile = None
49
+ self._api = None
50
+
51
+ def get_api(self) -> ClashAPI:
52
+ if self.profile is None:
53
+ raise RuntimeError("尚未选择端点档案")
54
+ if self._api is None:
55
+ debug = self.debug_mode or bool(self.profile.get("debug_ssh"))
56
+ self._api = ClashAPI(
57
+ base_url=self.profile["url"],
58
+ secret=self.profile.get("secret"),
59
+ working_directory=self.profile.get("config_directory"),
60
+ debug=debug,
61
+ log=self.add_log,
62
+ )
63
+ return self._api
64
+
65
+ # ------------------------------------------------------------ 状态栏 / 提示
66
+
67
+ def compose(self) -> ComposeResult:
68
+ yield StatusBar("", id="status")
69
+
70
+ def status(self, message: str, kind: str = "") -> None:
71
+ try:
72
+ bar = self.query_one("#status", StatusBar)
73
+ except Exception:
74
+ return
75
+ bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
76
+ bar.update(message)
77
+
78
+ def notify_ok(self, message: str) -> None:
79
+ self.status(message, "ok")
80
+ self.notify(message, severity="success", timeout=4)
81
+
82
+ def notify_err(self, message: str) -> None:
83
+ self.status(message, "err")
84
+ self.notify(message, severity="error", timeout=8)
85
+
86
+ def notify_warn(self, message: str) -> None:
87
+ self.status(message, "warn")
88
+ self.notify(message, severity="warning", timeout=5)
89
+
90
+ def report(self, action_desc: str, result, error) -> str:
91
+ """统一解释 ClashAPI 结果并反馈到状态栏 / 通知 / 日志,返回严重级别。"""
92
+ from .api import interpret_api_result
93
+
94
+ message, severity = interpret_api_result(action_desc, result, error)
95
+ self.add_log(message)
96
+ if self.debug_mode:
97
+ self.add_log(f"[DEBUG] result={result!r} error={error!r}")
98
+ if severity == "ok":
99
+ self.notify_ok(message)
100
+ elif severity == "warn":
101
+ self.notify_warn(message)
102
+ else:
103
+ self.notify_err(message)
104
+ return severity
105
+
106
+ # ------------------------------------------------------------ 启动
107
+
108
+ def on_mount(self) -> None:
109
+ self.add_log("Application started.")
110
+ self._bootstrap()
111
+
112
+ @work(exclusive=True)
113
+ async def _bootstrap(self) -> None:
114
+ from .screens.profiles import ProfileListScreen
115
+
116
+ self.push_screen(ProfileListScreen())