clash-controller 0.2.3__tar.gz → 0.2.5__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.
- {clash_controller-0.2.3 → clash_controller-0.2.5}/PKG-INFO +3 -7
- {clash_controller-0.2.3 → clash_controller-0.2.5}/README.md +2 -6
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/api.py +129 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/config.py +50 -5
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/overview.py +16 -2
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/profiles.py +39 -15
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/settings.py +10 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/ui.py +14 -2
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/widgets.py +28 -7
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/PKG-INFO +3 -7
- {clash_controller-0.2.3 → clash_controller-0.2.5}/pyproject.toml +1 -1
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/app.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/app.tcss +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/config.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/deploy.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/main.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/home.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller/ssh.py +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/SOURCES.txt +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.3 → clash_controller-0.2.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clash_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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
|
|
@@ -31,7 +31,7 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
31
31
|
|
|
32
32
|
## 功能特性
|
|
33
33
|
|
|
34
|
-
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 +
|
|
34
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,数据行单击选中、双击执行,非阻塞异步加载与应用内日志。
|
|
35
35
|
- **多端点管理**:
|
|
36
36
|
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
37
37
|
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
@@ -54,7 +54,7 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
54
54
|
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
55
55
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
56
56
|
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
|
|
57
|
-
| **配置管理** | `↑` / `↓` · `回车` 或
|
|
57
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
58
58
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
59
59
|
| | `Ctrl+R` | 刷新 |
|
|
60
60
|
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
@@ -64,10 +64,6 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
64
64
|
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
65
65
|
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
66
66
|
|
|
67
|
-
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
68
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
69
|
-
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
70
|
-
|
|
71
67
|
## 安装
|
|
72
68
|
|
|
73
69
|
```bash
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 功能特性
|
|
6
6
|
|
|
7
|
-
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 +
|
|
7
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,数据行单击选中、双击执行,非阻塞异步加载与应用内日志。
|
|
8
8
|
- **多端点管理**:
|
|
9
9
|
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
10
10
|
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
28
28
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
29
29
|
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
|
|
30
|
-
| **配置管理** | `↑` / `↓` · `回车` 或
|
|
30
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
31
31
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
32
32
|
| | `Ctrl+R` | 刷新 |
|
|
33
33
|
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
@@ -37,10 +37,6 @@
|
|
|
37
37
|
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
38
38
|
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
39
39
|
|
|
40
|
-
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
41
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
42
|
-
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
43
|
-
|
|
44
40
|
## 安装
|
|
45
41
|
|
|
46
42
|
```bash
|
|
@@ -320,6 +320,81 @@ class ClashAPI:
|
|
|
320
320
|
"""Update a rule provider."""
|
|
321
321
|
return self._request('PUT', f'/providers/rules/{quote(name)}')
|
|
322
322
|
|
|
323
|
+
def update_all_providers(self):
|
|
324
|
+
"""更新所有代理合集 (proxy-providers) 与规则合集 (rule-providers)。
|
|
325
|
+
|
|
326
|
+
由于部分内核(如 Clash 原版)可能不支持 rule-provider 或不完全支持 PUT 批量,
|
|
327
|
+
此处通过遍历获取到的 provider 列表进行逐一更新。
|
|
328
|
+
"""
|
|
329
|
+
# 1. 获取所有 Proxy Provider
|
|
330
|
+
proxy_res, err = self.get_proxy_providers()
|
|
331
|
+
if err:
|
|
332
|
+
return None, err
|
|
333
|
+
if isinstance(proxy_res, dict) and proxy_res.get("status_code") in (401, 403):
|
|
334
|
+
return None, f"鉴权失败 (HTTP {proxy_res.get('status_code')})"
|
|
335
|
+
|
|
336
|
+
# 2. 获取所有 Rule Provider
|
|
337
|
+
rule_res, err = self.get_rule_providers()
|
|
338
|
+
if err:
|
|
339
|
+
return None, err
|
|
340
|
+
if isinstance(rule_res, dict) and rule_res.get("status_code") in (401, 403):
|
|
341
|
+
return None, f"鉴权失败 (HTTP {rule_res.get('status_code')})"
|
|
342
|
+
|
|
343
|
+
# 解析名称列表
|
|
344
|
+
proxy_names = []
|
|
345
|
+
if isinstance(proxy_res, dict):
|
|
346
|
+
providers = proxy_res.get("providers")
|
|
347
|
+
if isinstance(providers, dict):
|
|
348
|
+
proxy_names = list(providers.keys())
|
|
349
|
+
|
|
350
|
+
rule_names = []
|
|
351
|
+
if isinstance(rule_res, dict):
|
|
352
|
+
providers = rule_res.get("providers")
|
|
353
|
+
if isinstance(providers, dict):
|
|
354
|
+
rule_names = list(providers.keys())
|
|
355
|
+
|
|
356
|
+
total = len(proxy_names) + len(rule_names)
|
|
357
|
+
if total == 0:
|
|
358
|
+
return {"status": "success", "message": "未检测到任何 Proxy Provider 或 Rule Provider"}, None
|
|
359
|
+
|
|
360
|
+
errors = []
|
|
361
|
+
success_count = 0
|
|
362
|
+
|
|
363
|
+
# 3. 逐一更新
|
|
364
|
+
for name in proxy_names:
|
|
365
|
+
res, err = self.update_proxy_provider(name)
|
|
366
|
+
if err:
|
|
367
|
+
errors.append(f"代理合集[{name}]: {err}")
|
|
368
|
+
elif isinstance(res, dict) and res.get("status_code") not in (200, 204) and res.get("status") != "success":
|
|
369
|
+
msg = res.get("message") or res.get("body") or f"HTTP {res.get('status_code')}"
|
|
370
|
+
errors.append(f"代理合集[{name}]: {msg}")
|
|
371
|
+
else:
|
|
372
|
+
success_count += 1
|
|
373
|
+
|
|
374
|
+
for name in rule_names:
|
|
375
|
+
res, err = self.update_rule_provider(name)
|
|
376
|
+
if err:
|
|
377
|
+
errors.append(f"规则合集[{name}]: {err}")
|
|
378
|
+
elif isinstance(res, dict) and res.get("status_code") not in (200, 204) and res.get("status") != "success":
|
|
379
|
+
msg = res.get("message") or res.get("body") or f"HTTP {res.get('status_code')}"
|
|
380
|
+
errors.append(f"规则合集[{name}]: {msg}")
|
|
381
|
+
else:
|
|
382
|
+
success_count += 1
|
|
383
|
+
|
|
384
|
+
if not errors:
|
|
385
|
+
return {
|
|
386
|
+
"status": "success",
|
|
387
|
+
"message": f"共更新 {len(proxy_names)} 个代理合集、{len(rule_names)} 个规则合集",
|
|
388
|
+
}, None
|
|
389
|
+
|
|
390
|
+
if success_count == 0:
|
|
391
|
+
return None, f"全部 Provider 更新失败: {'; '.join(errors)}"
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
"status": "warn",
|
|
395
|
+
"message": f"成功更新 {success_count}/{total} 个 Provider,失败:{'; '.join(errors)}",
|
|
396
|
+
}, None
|
|
397
|
+
|
|
323
398
|
# === Rules ===
|
|
324
399
|
def get_rules(self):
|
|
325
400
|
"""Get all rules."""
|
|
@@ -400,3 +475,57 @@ def interpret_api_result(action_desc: str, result, error) -> tuple[str, str]:
|
|
|
400
475
|
|
|
401
476
|
return (f"{action_desc}失败:{error}", "err")
|
|
402
477
|
|
|
478
|
+
|
|
479
|
+
def _brief(raw, limit: int = 120) -> str:
|
|
480
|
+
"""把任意响应体压成一行短文本,供提示栏/通知展示。"""
|
|
481
|
+
if raw is None:
|
|
482
|
+
return "无内容"
|
|
483
|
+
text = " ".join(str(raw).split())
|
|
484
|
+
if not text:
|
|
485
|
+
return "无内容"
|
|
486
|
+
return text[:limit] + "…" if len(text) > limit else text
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _transport_hint(error: str) -> str:
|
|
490
|
+
"""把 requests 的传输层错误翻译成人话,认不出来就截断原文。"""
|
|
491
|
+
low = error.lower()
|
|
492
|
+
if "connection refused" in low or "connection aborted" in low:
|
|
493
|
+
return "拒绝连接,请确认 Clash 已启动且控制端口正确"
|
|
494
|
+
if "max retries exceeded" in low:
|
|
495
|
+
return "无法连接到端点,请确认 Clash 已启动且地址端口正确"
|
|
496
|
+
if "timed out" in low:
|
|
497
|
+
return "连接超时,端点可能未响应"
|
|
498
|
+
if "name resolution" in low or "failed to resolve" in low or "nodename" in low:
|
|
499
|
+
return "无法解析主机地址"
|
|
500
|
+
if "ssl" in low or "certificate" in low:
|
|
501
|
+
return "TLS 握手失败"
|
|
502
|
+
if "authorization" in low or "unauthorized" in low:
|
|
503
|
+
return "鉴权失败,请检查 API 密钥"
|
|
504
|
+
return _brief(error)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def interpret_connect_result(result, error) -> tuple[bool, str]:
|
|
508
|
+
"""解释连接预检查(GET /version):返回 ``(能否使用, 版本号或失败原因)``。
|
|
509
|
+
|
|
510
|
+
只读探测不存在「写操作已发送但断连」的歧义,所以 ``SENT_BUT_DISCONNECTED``
|
|
511
|
+
在此按连不上处理(写操作仍由 ``interpret_api_result`` 保留原语义)。
|
|
512
|
+
Clash 对密钥错误返回 401 且带响应体,ClashAPI 会把它当正常结果返回,
|
|
513
|
+
因此这里必须校验状态码与版本字段,否则会把鉴权失败误判为连接成功。
|
|
514
|
+
"""
|
|
515
|
+
if error == ClashAPI.SENT_BUT_DISCONNECTED:
|
|
516
|
+
return False, "未能读回响应,端点可能未启动或未开放控制接口"
|
|
517
|
+
if error:
|
|
518
|
+
return False, _transport_hint(str(error))
|
|
519
|
+
if not isinstance(result, dict):
|
|
520
|
+
return False, f"端点返回了无法识别的内容:{_brief(result)}"
|
|
521
|
+
|
|
522
|
+
status_code = result.get("status_code")
|
|
523
|
+
if isinstance(status_code, int) and not 200 <= status_code < 300:
|
|
524
|
+
if status_code in (401, 403):
|
|
525
|
+
return False, f"鉴权失败(HTTP {status_code}),请检查端点 API 密钥"
|
|
526
|
+
return False, f"HTTP {status_code}:{_brief(result.get('body') or result.get('message'))}"
|
|
527
|
+
|
|
528
|
+
version = result.get("version")
|
|
529
|
+
if not version:
|
|
530
|
+
return False, f"响应中没有版本信息,可能不是 Clash 控制接口:{_brief(result)}"
|
|
531
|
+
return True, str(version)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""配置管理屏:配置提供方的增删改,以及拉取 / 比对 / 部署 / 重载流程。"""
|
|
2
2
|
import asyncio
|
|
3
|
+
import time
|
|
3
4
|
|
|
4
5
|
from rich.text import Text
|
|
5
6
|
from textual import on, work
|
|
@@ -11,14 +12,25 @@ from ..config import load_config_providers, save_config_providers
|
|
|
11
12
|
from ..deploy import compute_sha256, evaluate_update, fetch_remote_config, read_current_config
|
|
12
13
|
from ..ssh import apply_local_config_file, apply_remote_config_via_ssh
|
|
13
14
|
from ..ui import PageScreen
|
|
14
|
-
from ..widgets import
|
|
15
|
+
from ..widgets import (
|
|
16
|
+
ConfirmModal,
|
|
17
|
+
ControlBusy,
|
|
18
|
+
FormField,
|
|
19
|
+
FormModal,
|
|
20
|
+
busy,
|
|
21
|
+
fit_table_columns,
|
|
22
|
+
hold_busy,
|
|
23
|
+
load_rows,
|
|
24
|
+
make_table,
|
|
25
|
+
shorten,
|
|
26
|
+
)
|
|
15
27
|
|
|
16
28
|
COLUMN_WEIGHTS = [2, 7]
|
|
17
29
|
|
|
18
30
|
|
|
19
31
|
class ConfigScreen(PageScreen):
|
|
20
32
|
TITLE = "配置管理"
|
|
21
|
-
HINT = "↑↓ 移动 · 回车 /
|
|
33
|
+
HINT = "↑↓ 移动 · 回车 / 双击 拉取并部署所选 · Ctrl+N 新增 · Ctrl+E 编辑 · Ctrl+D 删除 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
22
34
|
|
|
23
35
|
BINDINGS = [
|
|
24
36
|
Binding("ctrl+n", "add_provider", show=False),
|
|
@@ -30,6 +42,7 @@ class ConfigScreen(PageScreen):
|
|
|
30
42
|
super().__init__()
|
|
31
43
|
self._providers: list = []
|
|
32
44
|
self._display: list = []
|
|
45
|
+
self._busy = ControlBusy(self)
|
|
33
46
|
|
|
34
47
|
def compose_page(self):
|
|
35
48
|
with Vertical(classes="panel"):
|
|
@@ -39,8 +52,9 @@ class ConfigScreen(PageScreen):
|
|
|
39
52
|
yield Button("编辑", id="edit")
|
|
40
53
|
yield Button("删除", id="delete", variant="error")
|
|
41
54
|
yield Button("重载配置", id="reload-api")
|
|
55
|
+
yield Button("更新Provider", id="update-providers")
|
|
42
56
|
yield Static("", classes="fill")
|
|
43
|
-
yield Static("
|
|
57
|
+
yield Static("双击行 = 部署该提供方", classes="fl-label")
|
|
44
58
|
yield make_table("名称", "地址", id="cf-table")
|
|
45
59
|
|
|
46
60
|
def on_mount(self) -> None:
|
|
@@ -240,10 +254,41 @@ class ConfigScreen(PageScreen):
|
|
|
240
254
|
|
|
241
255
|
@work(exclusive=True)
|
|
242
256
|
async def _reload_api(self) -> None:
|
|
243
|
-
|
|
257
|
+
"""就地忙碌态反馈(与设置页一致):禁用按钮 + 文本换成点阵 spinner。"""
|
|
258
|
+
api = self.app.get_api()
|
|
259
|
+
button = self.query_one("#reload-api", Button)
|
|
260
|
+
started = time.monotonic()
|
|
261
|
+
self._busy.start(button)
|
|
244
262
|
try:
|
|
245
|
-
result, error = await asyncio.to_thread(
|
|
263
|
+
result, error = await asyncio.to_thread(api.reload_configs)
|
|
264
|
+
await hold_busy(started)
|
|
246
265
|
except Exception as e: # noqa: BLE001
|
|
266
|
+
await hold_busy(started)
|
|
247
267
|
self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
|
|
248
268
|
return
|
|
269
|
+
finally:
|
|
270
|
+
self._busy.stop()
|
|
249
271
|
self.app.report("重载配置", result, error)
|
|
272
|
+
|
|
273
|
+
@on(Button.Pressed, "#update-providers")
|
|
274
|
+
def _on_update_providers(self) -> None:
|
|
275
|
+
self._update_providers()
|
|
276
|
+
|
|
277
|
+
@work(exclusive=True)
|
|
278
|
+
async def _update_providers(self) -> None:
|
|
279
|
+
"""就地忙碌态反馈:禁用按钮 + 文本换成点阵 spinner。"""
|
|
280
|
+
api = self.app.get_api()
|
|
281
|
+
button = self.query_one("#update-providers", Button)
|
|
282
|
+
started = time.monotonic()
|
|
283
|
+
self._busy.start(button)
|
|
284
|
+
try:
|
|
285
|
+
result, error = await asyncio.to_thread(api.update_all_providers)
|
|
286
|
+
await hold_busy(started)
|
|
287
|
+
except Exception as e: # noqa: BLE001
|
|
288
|
+
await hold_busy(started)
|
|
289
|
+
self.app.notify_err(f"更新Provider失败: {type(e).__name__}: {e}")
|
|
290
|
+
return
|
|
291
|
+
finally:
|
|
292
|
+
self._busy.stop()
|
|
293
|
+
self.app.report("更新Provider", result, error)
|
|
294
|
+
|
|
@@ -8,6 +8,7 @@ from datetime import datetime
|
|
|
8
8
|
from rich.text import Text
|
|
9
9
|
from textual import work
|
|
10
10
|
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.dom import NoScreen
|
|
11
12
|
from textual.widgets import DataTable, Static
|
|
12
13
|
|
|
13
14
|
from ..ui import PageScreen
|
|
@@ -16,6 +17,18 @@ from ..widgets import fit_table_columns, hold_busy, load_rows, make_table, short
|
|
|
16
17
|
COLUMN_WEIGHTS = [4, 1.5, 1.5, 3, 3]
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def _attached(widget) -> bool:
|
|
21
|
+
"""控件链是否仍连着屏幕。
|
|
22
|
+
|
|
23
|
+
页面退出时 worker 会被取消,而父链可能先于 `is_mounted` 复位被摘掉,
|
|
24
|
+
这时写 `table.loading` 会抛 NoScreen(覆盖层本来也随页面一起消失了)。
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
return widget.screen is not None
|
|
28
|
+
except NoScreen:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
19
32
|
class OverviewScreen(PageScreen):
|
|
20
33
|
TITLE = "概览"
|
|
21
34
|
HINT = "↑↓ / PgUp / PgDn 滚动连接表 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
@@ -185,8 +198,9 @@ class OverviewScreen(PageScreen):
|
|
|
185
198
|
if self.is_mounted:
|
|
186
199
|
await hold_busy(started)
|
|
187
200
|
finally:
|
|
188
|
-
table
|
|
189
|
-
|
|
201
|
+
if _attached(table):
|
|
202
|
+
table.loading = False
|
|
203
|
+
if _attached(table):
|
|
190
204
|
self.call_after_refresh(self._refit) # 覆盖层撤下后区域宽可能变化,按最终宽度重排
|
|
191
205
|
table.focus()
|
|
192
206
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""端点档案屏(根屏):居中 LOGO 版式,选择 / 连接 / 新增 / 编辑 / 删除(含 SSH 子表单)。"""
|
|
2
2
|
import asyncio
|
|
3
|
+
import time
|
|
3
4
|
|
|
4
5
|
from rich.text import Text
|
|
5
6
|
from textual import on, work
|
|
@@ -9,10 +10,11 @@ from textual.screen import Screen
|
|
|
9
10
|
from textual.widgets import Button, OptionList, Static
|
|
10
11
|
|
|
11
12
|
from .. import __version__
|
|
13
|
+
from ..api import interpret_connect_result
|
|
12
14
|
from ..config import load_profiles, save_profiles
|
|
13
15
|
from ..deploy import default_endpoint_type
|
|
14
16
|
from ..ui import LOGO_WIDTH, home_col_width, logo_text
|
|
15
|
-
from ..widgets import ConfirmModal, FormField, FormModal, HintBar, MenuMarquee
|
|
17
|
+
from ..widgets import ConfirmModal, FormField, FormModal, HintBar, MenuMarquee, busy, hold_busy
|
|
16
18
|
|
|
17
19
|
try: # textual 各版本 Option 导出位置不同
|
|
18
20
|
from textual.widgets.option_list import Option
|
|
@@ -308,24 +310,46 @@ class ProfileListScreen(Screen):
|
|
|
308
310
|
if error:
|
|
309
311
|
self.app.notify_err(f"{profile.get('name', '?')}: {error}")
|
|
310
312
|
return
|
|
313
|
+
name = str(profile.get("name", "?") or "?")
|
|
314
|
+
url = str(profile.get("url", "") or "")
|
|
311
315
|
self.app.set_profile(profile)
|
|
312
|
-
self.app.add_log(f"Selected profile '{
|
|
313
|
-
self.app.status(f"正在连接 {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
316
|
+
self.app.add_log(f"Selected profile '{name}'.")
|
|
317
|
+
self.app.status(f"正在连接 {url} …")
|
|
318
|
+
started = time.monotonic()
|
|
319
|
+
version_info = None
|
|
320
|
+
api_error = None
|
|
321
|
+
conn_exc: Exception | None = None
|
|
322
|
+
async with busy(self.app, f"正在连接 {url} …", mode="bar"):
|
|
323
|
+
try:
|
|
324
|
+
version_info, api_error = await asyncio.to_thread(self.app.get_api().get_version)
|
|
325
|
+
except Exception as e: # noqa: BLE001
|
|
326
|
+
conn_exc = e
|
|
327
|
+
# 本地 API 常在 1 帧内返回,补足最短可见时长,避免进度条「看不见」
|
|
328
|
+
await hold_busy(started)
|
|
329
|
+
|
|
330
|
+
if conn_exc is not None:
|
|
331
|
+
ok, detail = False, f"{type(conn_exc).__name__}: {conn_exc}"
|
|
332
|
+
else:
|
|
333
|
+
ok, detail = interpret_connect_result(version_info, api_error)
|
|
334
|
+
|
|
335
|
+
if not ok:
|
|
336
|
+
# 预检查不通过:留在端点选择界面,清掉档案,避免后续页面拿到坏客户端
|
|
337
|
+
message = f"无法连接端点 {name}({url}):{detail}"
|
|
338
|
+
self.app.add_log(message)
|
|
339
|
+
self.app.notify_err(message)
|
|
318
340
|
self.app.clear_profile()
|
|
341
|
+
self.call_after_refresh(self._focus_list)
|
|
319
342
|
return
|
|
320
|
-
if version_info:
|
|
321
|
-
version = version_info.get("version", "unknown")
|
|
322
|
-
self.app.add_log(f"Successfully connected to Clash (version: {version}).")
|
|
323
|
-
from .home import HomeScreen
|
|
324
343
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
344
|
+
self.app.add_log(f"Successfully connected to Clash (version: {detail}).")
|
|
345
|
+
self.app.status(f"已连接 {name} · {detail}", "ok")
|
|
346
|
+
from .home import HomeScreen
|
|
347
|
+
|
|
348
|
+
self.app.push_screen(HomeScreen())
|
|
349
|
+
|
|
350
|
+
def _focus_list(self) -> None:
|
|
351
|
+
if self.is_mounted:
|
|
352
|
+
self.query_one("#pf-list", OptionList).focus()
|
|
329
353
|
|
|
330
354
|
# ------------------------------------------------------------ 增删改
|
|
331
355
|
|
|
@@ -34,6 +34,8 @@ class SettingsScreen(PageScreen):
|
|
|
34
34
|
with Vertical(classes="panel"):
|
|
35
35
|
with Horizontal(classes="filter-row"):
|
|
36
36
|
yield Static("核心操作", classes="fl-label")
|
|
37
|
+
yield Button("重载配置", id="reload-conf")
|
|
38
|
+
yield Button("更新Provider", id="update-providers")
|
|
37
39
|
yield Button("重载 GEO", id="reload-geo")
|
|
38
40
|
with Horizontal(classes="filter-row gap-top"):
|
|
39
41
|
yield Static("重启升级", classes="fl-label")
|
|
@@ -100,6 +102,14 @@ class SettingsScreen(PageScreen):
|
|
|
100
102
|
"set_mode", f"切换模式为 {labels.get(mode, mode)}", mode, control_id="st-mode"
|
|
101
103
|
)
|
|
102
104
|
|
|
105
|
+
@on(Button.Pressed, "#reload-conf")
|
|
106
|
+
def _on_reload_conf(self) -> None:
|
|
107
|
+
self._call("reload_configs", "重载配置", control_id="reload-conf")
|
|
108
|
+
|
|
109
|
+
@on(Button.Pressed, "#update-providers")
|
|
110
|
+
def _on_update_providers(self) -> None:
|
|
111
|
+
self._call("update_all_providers", "更新Provider", control_id="update-providers")
|
|
112
|
+
|
|
103
113
|
@on(Button.Pressed, "#reload-geo")
|
|
104
114
|
def _on_reload_geo(self) -> None:
|
|
105
115
|
self._call("reload_geo_databases", "重载 GEO 数据库", control_id="reload-geo")
|
|
@@ -12,6 +12,7 @@ from rich.text import Text
|
|
|
12
12
|
from textual import on
|
|
13
13
|
from textual.binding import Binding
|
|
14
14
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
15
|
+
from textual.css.query import NoMatches
|
|
15
16
|
from textual.screen import Screen
|
|
16
17
|
from textual.widgets import Button, Input, OptionList, Static, TextArea
|
|
17
18
|
|
|
@@ -176,14 +177,25 @@ class PageScreen(Screen):
|
|
|
176
177
|
# ------------------------------------------------------------ 标题 / 副标题
|
|
177
178
|
|
|
178
179
|
def set_subtitle(self, text: str) -> None:
|
|
180
|
+
"""更新顶栏副标题。
|
|
181
|
+
|
|
182
|
+
判定不能用 ``is_mounted``:`on_mount` 里它仍是 `False`(Textual 派发完
|
|
183
|
+
Mount 事件之后才置位),首屏副标题会永远停在空串。改为按顶栏控件是否
|
|
184
|
+
已组合判断:未组合时仅保存文本,交由 compose 使用。
|
|
185
|
+
"""
|
|
179
186
|
self.subtitle = text
|
|
180
|
-
|
|
187
|
+
try:
|
|
181
188
|
self.query_one("#tb-sub", Static).update(text)
|
|
189
|
+
except NoMatches:
|
|
190
|
+
pass
|
|
182
191
|
|
|
183
192
|
def set_title(self, text: str) -> None:
|
|
193
|
+
"""更新顶栏标题;判定口径同 set_subtitle。"""
|
|
184
194
|
self.TITLE = text
|
|
185
|
-
|
|
195
|
+
try:
|
|
186
196
|
self.query_one(".tb-title", Static).update(Text(text, style="bold"))
|
|
197
|
+
except NoMatches:
|
|
198
|
+
pass
|
|
187
199
|
|
|
188
200
|
# ------------------------------------------------------------ 折叠菜单
|
|
189
201
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""共享 UI 组件:确认、输入、多字段表单、输出查看、表格与模糊筛选辅助。
|
|
2
2
|
|
|
3
3
|
所有模态均为 ModalScreen 泛型返回值,配合 push_screen_wait / 回调消费;
|
|
4
|
-
数据展示统一使用 DataTable(ClickTable
|
|
4
|
+
数据展示统一使用 DataTable(ClickTable 单击选中、双击执行),不构造字符串伪表格。
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
@@ -627,32 +627,53 @@ class MenuMarquee:
|
|
|
627
627
|
|
|
628
628
|
|
|
629
629
|
class ClickTable(DataTable):
|
|
630
|
-
"""
|
|
630
|
+
"""可操作表格:单击只选中,双击(或 Enter)才执行。
|
|
631
|
+
|
|
632
|
+
``activate="double"``(默认):单击数据格只移动光标(发 ``RowHighlighted``,
|
|
633
|
+
供状态栏 / 控制按钮刷新),``event.chain >= 2``(同格 0.5s 内第二次点击)
|
|
634
|
+
才发 ``RowSelected``。避免误点一下就把详情 / 表单顶到脸上。
|
|
635
|
+
|
|
636
|
+
``activate="click"``:单击即发 ``RowSelected``,用于「选中即生效」的选择器
|
|
637
|
+
(如格式列表、纯选择用途的主从列表)。
|
|
631
638
|
|
|
632
639
|
注意:Textual 沿 MRO 逐类派发事件,重写 `_on_click` 后父类实现仍会被调用一次,
|
|
633
640
|
因此接管分支与回落分支都必须 prevent_default(),防止 RowSelected 双发。
|
|
634
641
|
"""
|
|
635
642
|
|
|
643
|
+
def __init__(self, *, activate: str = "double", **kwargs) -> None:
|
|
644
|
+
super().__init__(**kwargs)
|
|
645
|
+
self.activate_mode = "click" if activate == "click" else "double"
|
|
646
|
+
|
|
636
647
|
async def _on_click(self, event: events.Click) -> None:
|
|
637
648
|
meta = event.style.meta if event.style is not None else {}
|
|
638
649
|
row = meta.get("row", -1)
|
|
639
650
|
col = meta.get("column", -1)
|
|
640
651
|
is_data_cell = (
|
|
641
652
|
self.cursor_type == "row"
|
|
642
|
-
and isinstance(row, int)
|
|
643
|
-
and
|
|
653
|
+
and isinstance(row, int)
|
|
654
|
+
and isinstance(col, int)
|
|
655
|
+
and 0 <= row < self.row_count
|
|
656
|
+
and col >= 0
|
|
644
657
|
and not meta.get("out_of_bounds", False)
|
|
645
658
|
)
|
|
646
659
|
if is_data_cell:
|
|
647
660
|
self.cursor_coordinate = Coordinate(row, col)
|
|
648
|
-
self.
|
|
661
|
+
if self.activate_mode == "click" or event.chain >= 2:
|
|
662
|
+
self._post_selected_message()
|
|
649
663
|
else:
|
|
650
664
|
await super()._on_click(event)
|
|
651
665
|
event.prevent_default()
|
|
652
666
|
|
|
653
667
|
|
|
654
|
-
def make_table(
|
|
655
|
-
|
|
668
|
+
def make_table(
|
|
669
|
+
*headers,
|
|
670
|
+
cursor: str = "row",
|
|
671
|
+
zebra: bool = True,
|
|
672
|
+
classes: str = "tbl",
|
|
673
|
+
activate: str = "double",
|
|
674
|
+
**kwargs,
|
|
675
|
+
) -> ClickTable:
|
|
676
|
+
dt = ClickTable(cursor_type=cursor, zebra_stripes=zebra, classes=classes, activate=activate, **kwargs)
|
|
656
677
|
dt.add_columns(*headers)
|
|
657
678
|
return dt
|
|
658
679
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clash_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
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
|
|
@@ -31,7 +31,7 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
31
31
|
|
|
32
32
|
## 功能特性
|
|
33
33
|
|
|
34
|
-
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 +
|
|
34
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,数据行单击选中、双击执行,非阻塞异步加载与应用内日志。
|
|
35
35
|
- **多端点管理**:
|
|
36
36
|
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
37
37
|
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
@@ -54,7 +54,7 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
54
54
|
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
55
55
|
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
56
56
|
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
|
|
57
|
-
| **配置管理** | `↑` / `↓` · `回车` 或
|
|
57
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
58
58
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
59
59
|
| | `Ctrl+R` | 刷新 |
|
|
60
60
|
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
@@ -64,10 +64,6 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
64
64
|
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
65
65
|
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
66
66
|
|
|
67
|
-
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
68
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
69
|
-
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
70
|
-
|
|
71
67
|
## 安装
|
|
72
68
|
|
|
73
69
|
```bash
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{clash_controller-0.2.3 → clash_controller-0.2.5}/clash_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|