clash-controller 0.2.2__tar.gz → 0.2.4__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.2 → clash_controller-0.2.4}/PKG-INFO +1 -1
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/api.py +129 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/app.py +66 -3
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/config.py +100 -54
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/overview.py +72 -43
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/profiles.py +39 -15
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/settings.py +39 -12
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/ui.py +14 -2
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/widgets.py +282 -2
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/PKG-INFO +1 -1
- {clash_controller-0.2.2 → clash_controller-0.2.4}/pyproject.toml +1 -1
- {clash_controller-0.2.2 → clash_controller-0.2.4}/README.md +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/app.tcss +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/config.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/deploy.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/main.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/home.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller/ssh.py +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/SOURCES.txt +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.2 → clash_controller-0.2.4}/setup.cfg +0 -0
|
@@ -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)
|
|
@@ -2,16 +2,63 @@
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
+
from rich.spinner import SPINNERS
|
|
6
|
+
from rich.text import Text
|
|
5
7
|
from textual import work
|
|
6
8
|
from textual.app import App, ComposeResult
|
|
7
9
|
from textual.binding import Binding
|
|
10
|
+
from textual.widget import Widget
|
|
8
11
|
from textual.widgets import Static
|
|
9
12
|
|
|
10
13
|
from .api import ClashAPI
|
|
14
|
+
from .widgets import ProgressCover
|
|
15
|
+
|
|
16
|
+
_DOTS = SPINNERS["dots"]
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
class StatusBar(Static):
|
|
14
|
-
"""底部状态栏:展示最近一次操作结果 /
|
|
20
|
+
"""底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
23
|
+
super().__init__(*args, **kwargs)
|
|
24
|
+
self._text = ""
|
|
25
|
+
self._busy = False
|
|
26
|
+
self._frame = 0
|
|
27
|
+
self._timer = None
|
|
28
|
+
|
|
29
|
+
def render(self) -> Text:
|
|
30
|
+
if self._busy:
|
|
31
|
+
glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
|
|
32
|
+
return Text.assemble((glyph, "bold"), " ", self._text)
|
|
33
|
+
return Text(self._text)
|
|
34
|
+
|
|
35
|
+
def set_status(self, text: str) -> None:
|
|
36
|
+
self._busy = False
|
|
37
|
+
self._text = text
|
|
38
|
+
self._stop_timer()
|
|
39
|
+
self.refresh()
|
|
40
|
+
|
|
41
|
+
def set_spinner(self, text: str) -> None:
|
|
42
|
+
self._busy = True
|
|
43
|
+
self._text = text
|
|
44
|
+
self._ensure_timer()
|
|
45
|
+
self.refresh()
|
|
46
|
+
|
|
47
|
+
def _ensure_timer(self) -> None:
|
|
48
|
+
if self._timer is None and self.is_mounted:
|
|
49
|
+
self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
|
|
50
|
+
|
|
51
|
+
def _stop_timer(self) -> None:
|
|
52
|
+
if self._timer is not None:
|
|
53
|
+
self._timer.stop()
|
|
54
|
+
self._timer = None
|
|
55
|
+
|
|
56
|
+
def _tick(self) -> None:
|
|
57
|
+
self._frame += 1
|
|
58
|
+
self.refresh()
|
|
59
|
+
|
|
60
|
+
def on_mount(self) -> None:
|
|
61
|
+
self._ensure_timer()
|
|
15
62
|
|
|
16
63
|
|
|
17
64
|
class ClashControllerApp(App):
|
|
@@ -67,13 +114,29 @@ class ClashControllerApp(App):
|
|
|
67
114
|
def compose(self) -> ComposeResult:
|
|
68
115
|
yield StatusBar("", id="status")
|
|
69
116
|
|
|
117
|
+
def get_loading_widget(self) -> Widget:
|
|
118
|
+
"""全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
|
|
119
|
+
return ProgressCover()
|
|
120
|
+
|
|
70
121
|
def status(self, message: str, kind: str = "") -> None:
|
|
71
122
|
try:
|
|
72
123
|
bar = self.query_one("#status", StatusBar)
|
|
73
|
-
except Exception:
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
74
125
|
return
|
|
75
126
|
bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
|
|
76
|
-
bar.
|
|
127
|
+
bar.set_status(message)
|
|
128
|
+
|
|
129
|
+
def spinner(self, on: bool, message: str = "加载中…") -> None:
|
|
130
|
+
"""状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
|
|
131
|
+
try:
|
|
132
|
+
bar = self.query_one("#status", StatusBar)
|
|
133
|
+
except Exception: # noqa: BLE001
|
|
134
|
+
return
|
|
135
|
+
bar.update_classes({})
|
|
136
|
+
if on:
|
|
137
|
+
bar.set_spinner(message)
|
|
138
|
+
else:
|
|
139
|
+
bar.set_status("")
|
|
77
140
|
|
|
78
141
|
def notify_ok(self, message: str) -> None:
|
|
79
142
|
self.status(message, "ok")
|
|
@@ -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,7 +12,18 @@ 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
|
|
|
@@ -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,6 +52,7 @@ 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
57
|
yield Static("单击行 = 部署该提供方", classes="fl-label")
|
|
44
58
|
yield make_table("名称", "地址", id="cf-table")
|
|
@@ -176,61 +190,62 @@ class ConfigScreen(PageScreen):
|
|
|
176
190
|
debug = self.app.debug_mode or bool(profile.get("debug_ssh"))
|
|
177
191
|
url = provider.get("url", "")
|
|
178
192
|
|
|
179
|
-
self.app
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
async with busy(self.app, "正在拉取远程配置…", mode="bar") as ov:
|
|
194
|
+
self.app.add_log(f"Fetching config from {url}...")
|
|
195
|
+
try:
|
|
196
|
+
remote_payload, remote_hash, remote_mod_time = await asyncio.to_thread(fetch_remote_config, url)
|
|
197
|
+
ov.set_message("正在读取目标配置…")
|
|
198
|
+
local_mtime, local_bytes = await asyncio.to_thread(
|
|
199
|
+
read_current_config, profile, self.app.add_log, debug
|
|
200
|
+
)
|
|
201
|
+
except Exception as e: # noqa: BLE001
|
|
202
|
+
self.app.notify_err(f"拉取或读取配置失败: {type(e).__name__}: {e}")
|
|
203
|
+
return
|
|
189
204
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
proceed = True
|
|
194
|
-
if decision == "auto":
|
|
195
|
-
self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
|
|
196
|
-
elif decision == "confirm_older":
|
|
197
|
-
proceed = await self.app.push_screen_wait(
|
|
198
|
-
ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
|
|
199
|
-
)
|
|
200
|
-
elif decision == "confirm_unknown":
|
|
201
|
-
proceed = await self.app.push_screen_wait(
|
|
202
|
-
ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
|
|
203
|
-
)
|
|
204
|
-
else: # confirm_same
|
|
205
|
-
proceed = await self.app.push_screen_wait(
|
|
206
|
-
ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
if not proceed:
|
|
210
|
-
self.app.status("已取消更新", "warn")
|
|
211
|
-
self.app.add_log("Config update cancelled by user after comparison.")
|
|
212
|
-
return
|
|
205
|
+
local_hash = compute_sha256(local_bytes)
|
|
206
|
+
decision = evaluate_update(remote_hash, remote_mod_time, local_hash, local_mtime)
|
|
213
207
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
await
|
|
219
|
-
|
|
208
|
+
proceed = True
|
|
209
|
+
if decision == "auto":
|
|
210
|
+
self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
|
|
211
|
+
elif decision == "confirm_older":
|
|
212
|
+
proceed = await self.app.push_screen_wait(
|
|
213
|
+
ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
|
|
214
|
+
)
|
|
215
|
+
elif decision == "confirm_unknown":
|
|
216
|
+
proceed = await self.app.push_screen_wait(
|
|
217
|
+
ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
|
|
218
|
+
)
|
|
219
|
+
else: # confirm_same
|
|
220
|
+
proceed = await self.app.push_screen_wait(
|
|
221
|
+
ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
|
|
220
222
|
)
|
|
221
|
-
else:
|
|
222
|
-
self.app.add_log("Deploying config to local endpoint file.")
|
|
223
|
-
await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
|
|
224
|
-
except Exception as e: # noqa: BLE001
|
|
225
|
-
self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
|
|
226
|
-
return
|
|
227
223
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
if not proceed:
|
|
225
|
+
self.app.status("已取消更新", "warn")
|
|
226
|
+
self.app.add_log("Config update cancelled by user after comparison.")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
ov.set_message("正在部署配置…")
|
|
230
|
+
try:
|
|
231
|
+
if profile.get("endpoint_type") == "remote":
|
|
232
|
+
self.app.add_log("Deploying config to remote endpoint via SSH.")
|
|
233
|
+
await asyncio.to_thread(
|
|
234
|
+
apply_remote_config_via_ssh, profile, remote_payload, self.app.add_log, debug
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
self.app.add_log("Deploying config to local endpoint file.")
|
|
238
|
+
await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
|
|
239
|
+
except Exception as e: # noqa: BLE001
|
|
240
|
+
self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
ov.set_message("正在通过 Clash API 重载配置…")
|
|
244
|
+
try:
|
|
245
|
+
result, error = await asyncio.to_thread(self.app.get_api().reload_configs)
|
|
246
|
+
except Exception as e: # noqa: BLE001
|
|
247
|
+
self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
|
|
248
|
+
return
|
|
234
249
|
self.app.report("重载配置", result, error)
|
|
235
250
|
|
|
236
251
|
@on(Button.Pressed, "#reload-api")
|
|
@@ -239,10 +254,41 @@ class ConfigScreen(PageScreen):
|
|
|
239
254
|
|
|
240
255
|
@work(exclusive=True)
|
|
241
256
|
async def _reload_api(self) -> None:
|
|
242
|
-
|
|
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)
|
|
243
262
|
try:
|
|
244
|
-
result, error = await asyncio.to_thread(
|
|
263
|
+
result, error = await asyncio.to_thread(api.reload_configs)
|
|
264
|
+
await hold_busy(started)
|
|
245
265
|
except Exception as e: # noqa: BLE001
|
|
266
|
+
await hold_busy(started)
|
|
246
267
|
self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
|
|
247
268
|
return
|
|
269
|
+
finally:
|
|
270
|
+
self._busy.stop()
|
|
248
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
|
+
|
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
"""概览屏:版本 + 实时流量 / 内存(流式接口)+ 当前活动连接表(每秒轮询)。"""
|
|
2
2
|
import asyncio
|
|
3
3
|
import json
|
|
4
|
+
import time
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
|
|
7
8
|
from rich.text import Text
|
|
8
9
|
from textual import work
|
|
9
10
|
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.dom import NoScreen
|
|
10
12
|
from textual.widgets import DataTable, Static
|
|
11
13
|
|
|
12
14
|
from ..ui import PageScreen
|
|
13
|
-
from ..widgets import fit_table_columns, load_rows, make_table, shorten
|
|
15
|
+
from ..widgets import fit_table_columns, hold_busy, load_rows, make_table, shorten
|
|
14
16
|
|
|
15
17
|
COLUMN_WEIGHTS = [4, 1.5, 1.5, 3, 3]
|
|
16
18
|
|
|
17
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
|
+
|
|
18
32
|
class OverviewScreen(PageScreen):
|
|
19
33
|
TITLE = "概览"
|
|
20
34
|
HINT = "↑↓ / PgUp / PgDn 滚动连接表 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
@@ -55,7 +69,7 @@ class OverviewScreen(PageScreen):
|
|
|
55
69
|
self._consume("内存", api.get_memory_stream, self._on_memory)
|
|
56
70
|
self.query_one(DataTable).focus()
|
|
57
71
|
self._timer = self.set_interval(1.0, self._refresh_connections)
|
|
58
|
-
self._refresh_connections()
|
|
72
|
+
self._refresh_connections(show_overlay=True)
|
|
59
73
|
|
|
60
74
|
def on_unmount(self) -> None:
|
|
61
75
|
self._stop = True
|
|
@@ -72,7 +86,7 @@ class OverviewScreen(PageScreen):
|
|
|
72
86
|
|
|
73
87
|
def reload_page(self) -> None:
|
|
74
88
|
self._fetch_version()
|
|
75
|
-
self._refresh_connections()
|
|
89
|
+
self._refresh_connections(show_overlay=True)
|
|
76
90
|
|
|
77
91
|
# ------------------------------------------------------------ 后台线程
|
|
78
92
|
|
|
@@ -130,50 +144,65 @@ class OverviewScreen(PageScreen):
|
|
|
130
144
|
fit_table_columns(self.query_one("#cn-table", DataTable), COLUMN_WEIGHTS, rows=self._count)
|
|
131
145
|
|
|
132
146
|
@work(exclusive=False)
|
|
133
|
-
async def _refresh_connections(self) -> None:
|
|
134
|
-
if self._busy
|
|
147
|
+
async def _refresh_connections(self, show_overlay: bool = False) -> None:
|
|
148
|
+
if self._busy:
|
|
135
149
|
return
|
|
136
150
|
self._busy = True
|
|
151
|
+
started = time.monotonic()
|
|
152
|
+
table = self.query_one("#cn-table", DataTable)
|
|
153
|
+
if show_overlay:
|
|
154
|
+
table.loading = True
|
|
137
155
|
try:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
f" · 更新于 {datetime.now():%H:%M:%S}"
|
|
156
|
+
try:
|
|
157
|
+
data, error = await asyncio.to_thread(self.app.get_api().get_connections)
|
|
158
|
+
except Exception as e: # noqa: BLE001
|
|
159
|
+
self.app.notify_err(f"获取连接失败: {type(e).__name__}: {e}")
|
|
160
|
+
return
|
|
161
|
+
if not self.is_mounted:
|
|
162
|
+
return
|
|
163
|
+
if error:
|
|
164
|
+
self.app.notify_err(f"获取连接失败: {error}")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
data = data if isinstance(data, dict) else {}
|
|
168
|
+
connections = data.get("connections", []) or []
|
|
169
|
+
total_dl = data.get("downloadTotal", 0) / (1024 * 1024)
|
|
170
|
+
total_ul = data.get("uploadTotal", 0) / (1024 * 1024)
|
|
171
|
+
|
|
172
|
+
rows = []
|
|
173
|
+
for conn in connections:
|
|
174
|
+
metadata = conn.get("metadata", {}) or {}
|
|
175
|
+
host = metadata.get("host") or metadata.get("destinationIP", "未知")
|
|
176
|
+
rows.append([
|
|
177
|
+
Text(shorten(str(host), 40)),
|
|
178
|
+
Text(str(metadata.get("network", "?"))),
|
|
179
|
+
Text(str(metadata.get("type", "?"))),
|
|
180
|
+
Text(shorten(str(conn.get("rule", "?")), 30)),
|
|
181
|
+
Text(shorten(" -> ".join(conn.get("chains", []) or []), 30)),
|
|
182
|
+
])
|
|
183
|
+
self._count = len(rows)
|
|
184
|
+
load_rows(table, rows)
|
|
185
|
+
self._refit()
|
|
186
|
+
self.query_one("#cn-summary", Static).update(
|
|
187
|
+
Text(
|
|
188
|
+
f"{len(connections)} 条 · 上传 {total_ul:.2f} MB / 下载 {total_dl:.2f} MB"
|
|
189
|
+
f" · 更新于 {datetime.now():%H:%M:%S}"
|
|
190
|
+
)
|
|
174
191
|
)
|
|
175
|
-
|
|
176
|
-
|
|
192
|
+
self.app.status(f"连接总数 {len(connections)}")
|
|
193
|
+
finally:
|
|
194
|
+
self._busy = False
|
|
195
|
+
if show_overlay:
|
|
196
|
+
# 无论正常/异常/取消,覆盖层都必须撤下
|
|
197
|
+
try:
|
|
198
|
+
if self.is_mounted:
|
|
199
|
+
await hold_busy(started)
|
|
200
|
+
finally:
|
|
201
|
+
if _attached(table):
|
|
202
|
+
table.loading = False
|
|
203
|
+
if _attached(table):
|
|
204
|
+
self.call_after_refresh(self._refit) # 覆盖层撤下后区域宽可能变化,按最终宽度重排
|
|
205
|
+
table.focus()
|
|
177
206
|
|
|
178
207
|
# ------------------------------------------------------------ UI 更新
|
|
179
208
|
|
|
@@ -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
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""设置屏:TUN / 模式即时生效控件(无保存动作)、重载与升级操作;日志入口在顶栏折叠菜单。"""
|
|
2
2
|
import asyncio
|
|
3
|
+
import time
|
|
3
4
|
|
|
4
5
|
from textual import on, work
|
|
5
6
|
from textual.containers import Horizontal, Vertical
|
|
6
7
|
from textual.widgets import Button, Select, Static, Switch
|
|
7
8
|
|
|
8
9
|
from ..ui import PageScreen
|
|
9
|
-
from ..widgets import ConfirmModal
|
|
10
|
+
from ..widgets import ConfirmModal, ControlBusy, hold_busy
|
|
10
11
|
|
|
11
12
|
MODES = [("规则", "rule"), ("全局", "global"), ("直连", "direct")]
|
|
12
13
|
|
|
@@ -19,6 +20,7 @@ class SettingsScreen(PageScreen):
|
|
|
19
20
|
super().__init__()
|
|
20
21
|
self._tun = False
|
|
21
22
|
self._mode = "rule"
|
|
23
|
+
self._busy = ControlBusy(self)
|
|
22
24
|
self.menu = [("查看应用日志", self._open_logs, False)]
|
|
23
25
|
|
|
24
26
|
def compose_page(self):
|
|
@@ -32,6 +34,8 @@ class SettingsScreen(PageScreen):
|
|
|
32
34
|
with Vertical(classes="panel"):
|
|
33
35
|
with Horizontal(classes="filter-row"):
|
|
34
36
|
yield Static("核心操作", classes="fl-label")
|
|
37
|
+
yield Button("重载配置", id="reload-conf")
|
|
38
|
+
yield Button("更新Provider", id="update-providers")
|
|
35
39
|
yield Button("重载 GEO", id="reload-geo")
|
|
36
40
|
with Horizontal(classes="filter-row gap-top"):
|
|
37
41
|
yield Static("重启升级", classes="fl-label")
|
|
@@ -83,7 +87,7 @@ class SettingsScreen(PageScreen):
|
|
|
83
87
|
if bool(event.value) == self._tun:
|
|
84
88
|
return
|
|
85
89
|
self._tun = bool(event.value)
|
|
86
|
-
self._call("toggle_tun", f"{'启用' if self._tun else '禁用'} TUN", self._tun)
|
|
90
|
+
self._call("toggle_tun", f"{'启用' if self._tun else '禁用'} TUN", self._tun, control_id="st-tun")
|
|
87
91
|
|
|
88
92
|
@on(Select.Changed, "#st-mode")
|
|
89
93
|
def _on_mode_changed(self, event: Select.Changed) -> None:
|
|
@@ -94,45 +98,68 @@ class SettingsScreen(PageScreen):
|
|
|
94
98
|
return
|
|
95
99
|
self._mode = mode
|
|
96
100
|
labels = dict(MODES)
|
|
97
|
-
self._call(
|
|
101
|
+
self._call(
|
|
102
|
+
"set_mode", f"切换模式为 {labels.get(mode, mode)}", mode, control_id="st-mode"
|
|
103
|
+
)
|
|
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")
|
|
98
112
|
|
|
99
113
|
@on(Button.Pressed, "#reload-geo")
|
|
100
114
|
def _on_reload_geo(self) -> None:
|
|
101
|
-
self._call("reload_geo_databases", "重载 GEO 数据库")
|
|
115
|
+
self._call("reload_geo_databases", "重载 GEO 数据库", control_id="reload-geo")
|
|
102
116
|
|
|
103
117
|
@on(Button.Pressed, "#restart")
|
|
104
118
|
def _on_restart(self) -> None:
|
|
105
|
-
self._confirm_then("确定要重启 Clash Core 吗?", "restart", "重启 Clash Core")
|
|
119
|
+
self._confirm_then("确定要重启 Clash Core 吗?", "restart", "重启 Clash Core", "restart")
|
|
106
120
|
|
|
107
121
|
@on(Button.Pressed, "#up-kernel")
|
|
108
122
|
def _on_up_kernel(self) -> None:
|
|
109
|
-
self._confirm_then("确定要请求升级内核吗?", "upgrade_kernel", "升级内核")
|
|
123
|
+
self._confirm_then("确定要请求升级内核吗?", "upgrade_kernel", "升级内核", "up-kernel")
|
|
110
124
|
|
|
111
125
|
@on(Button.Pressed, "#up-ui")
|
|
112
126
|
def _on_up_ui(self) -> None:
|
|
113
|
-
self._confirm_then("确定要请求升级 UI 吗?", "upgrade_ui", "升级 UI")
|
|
127
|
+
self._confirm_then("确定要请求升级 UI 吗?", "upgrade_ui", "升级 UI", "up-ui")
|
|
114
128
|
|
|
115
129
|
@on(Button.Pressed, "#up-geo")
|
|
116
130
|
def _on_up_geo(self) -> None:
|
|
117
|
-
self._confirm_then("确定要请求升级 GEO 数据库吗?", "upgrade_geo_databases", "升级 GEO 数据库")
|
|
131
|
+
self._confirm_then("确定要请求升级 GEO 数据库吗?", "upgrade_geo_databases", "升级 GEO 数据库", "up-geo")
|
|
118
132
|
|
|
119
|
-
def _confirm_then(self, message: str, method_name: str, desc: str) -> None:
|
|
133
|
+
def _confirm_then(self, message: str, method_name: str, desc: str, control_id: str) -> None:
|
|
120
134
|
def confirm(ok: bool) -> None:
|
|
121
135
|
if ok:
|
|
122
|
-
self._call(method_name, desc)
|
|
136
|
+
self._call(method_name, desc, control_id=control_id)
|
|
123
137
|
|
|
124
138
|
self.app.push_screen(ConfirmModal(message, default_yes=False), confirm)
|
|
125
139
|
|
|
126
140
|
@work(exclusive=True)
|
|
127
|
-
async def _call(self, method_name: str, desc: str, *args) -> None:
|
|
141
|
+
async def _call(self, method_name: str, desc: str, *args, control_id: str | None = None) -> None:
|
|
128
142
|
api = self.app.get_api()
|
|
129
143
|
method = getattr(api, method_name)
|
|
130
|
-
|
|
144
|
+
started = time.monotonic()
|
|
145
|
+
control = self.query_one(f"#{control_id}") if control_id else None
|
|
146
|
+
if control is not None:
|
|
147
|
+
# 就地反馈:禁用触发控件并把文本换成点阵 spinner(比顶栏更直观)
|
|
148
|
+
self._busy.start(control)
|
|
149
|
+
else:
|
|
150
|
+
self.app.spinner(True, f"正在{desc}…")
|
|
131
151
|
try:
|
|
132
152
|
result, error = await asyncio.to_thread(method, *args)
|
|
153
|
+
await hold_busy(started)
|
|
133
154
|
except Exception as e: # noqa: BLE001
|
|
155
|
+
await hold_busy(started)
|
|
134
156
|
self.app.notify_err(f"{desc}失败: {type(e).__name__}: {e}")
|
|
135
157
|
self._load_configs() # 失败回滚控件到服务端真实状态
|
|
136
158
|
return
|
|
159
|
+
finally:
|
|
160
|
+
if control is not None:
|
|
161
|
+
self._busy.stop()
|
|
162
|
+
else:
|
|
163
|
+
self.app.spinner(False)
|
|
137
164
|
self.app.report(desc, result, error)
|
|
138
165
|
self._load_configs() # 以服务端状态为准回写控件(失败时即回滚)
|
|
@@ -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
|
|
|
@@ -3,18 +3,40 @@
|
|
|
3
3
|
所有模态均为 ModalScreen 泛型返回值,配合 push_screen_wait / 回调消费;
|
|
4
4
|
数据展示统一使用 DataTable(ClickTable 单击执行),不构造字符串伪表格。
|
|
5
5
|
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import time
|
|
6
11
|
from collections.abc import Callable
|
|
7
12
|
from dataclasses import dataclass, field
|
|
8
13
|
from typing import Any
|
|
9
14
|
|
|
10
15
|
from rich.cells import cell_len
|
|
16
|
+
from rich.spinner import SPINNERS
|
|
17
|
+
from rich.style import Style
|
|
11
18
|
from rich.text import Text
|
|
12
19
|
from textual import events
|
|
13
20
|
from textual.binding import Binding
|
|
21
|
+
from textual.clock import Clock
|
|
14
22
|
from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
|
|
15
23
|
from textual.coordinate import Coordinate
|
|
24
|
+
from textual.css.query import NoMatches
|
|
16
25
|
from textual.screen import ModalScreen
|
|
17
|
-
from textual.
|
|
26
|
+
from textual.widget import Widget
|
|
27
|
+
from textual.widgets import (
|
|
28
|
+
Button,
|
|
29
|
+
DataTable,
|
|
30
|
+
Input,
|
|
31
|
+
Label,
|
|
32
|
+
LoadingIndicator,
|
|
33
|
+
OptionList,
|
|
34
|
+
ProgressBar,
|
|
35
|
+
Select,
|
|
36
|
+
Static,
|
|
37
|
+
Switch,
|
|
38
|
+
TextArea,
|
|
39
|
+
)
|
|
18
40
|
|
|
19
41
|
try: # textual 各版本 Option 导出位置不同
|
|
20
42
|
from textual.widgets.option_list import Option
|
|
@@ -340,7 +362,7 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
340
362
|
text = HINT_FORM
|
|
341
363
|
self.query_one("#frm-hint", HintBar).update(text)
|
|
342
364
|
|
|
343
|
-
def _collect(self) ->
|
|
365
|
+
def _collect(self) -> dict | None:
|
|
344
366
|
"""收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
|
|
345
367
|
err_box = self.query_one("#frm-error", Static)
|
|
346
368
|
err_box.update(Text(""))
|
|
@@ -699,10 +721,268 @@ def fit_table_columns(table: DataTable, weights: list[float], rows: int | None =
|
|
|
699
721
|
return visible
|
|
700
722
|
|
|
701
723
|
|
|
724
|
+
# ---------------------------------------------------------------- 加载指示器
|
|
725
|
+
|
|
726
|
+
class _IndeterminateBar:
|
|
727
|
+
"""自渲染不定进度条:按**绘制时**的区域宽度取景并居中。
|
|
728
|
+
|
|
729
|
+
不依赖 ``Widget.size``:覆盖层是交给合成器临时绘制的,首帧 ``_size`` 可能
|
|
730
|
+
尚未由 ``_size_updated`` 写入,若在 ``render()`` 里读 ``self.size`` 会拿到 0
|
|
731
|
+
而画出一格宽的「隐形条」。改用 Rich 渲染协议,``options.max_width`` 即为本次
|
|
732
|
+
真正要绘制的区域宽度,保证任何一帧都居中可见。
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
def __init__(
|
|
736
|
+
self, clock: Clock, animation_level: str, highlight: Style, track: Style, cap: int
|
|
737
|
+
) -> None:
|
|
738
|
+
self._clock = clock
|
|
739
|
+
self._animation_level = animation_level
|
|
740
|
+
self._highlight = highlight
|
|
741
|
+
self._track = track
|
|
742
|
+
self._cap = cap
|
|
743
|
+
|
|
744
|
+
def __rich_console__(self, console, options): # noqa: ANN001, ANN201
|
|
745
|
+
total = max(1, options.max_width)
|
|
746
|
+
width = min(total, self._cap)
|
|
747
|
+
bar = max(2, int(width * 0.25))
|
|
748
|
+
span = width + bar
|
|
749
|
+
if self._animation_level == "none":
|
|
750
|
+
start = 0
|
|
751
|
+
else:
|
|
752
|
+
speed = 30 # cells/s,与 Textual ProgressBar 的 indeterminate 一致
|
|
753
|
+
pos = int((speed * self._clock.time) % (2 * span))
|
|
754
|
+
if pos > span:
|
|
755
|
+
pos = 2 * span - pos
|
|
756
|
+
start = pos - bar
|
|
757
|
+
text = Text()
|
|
758
|
+
for i in range(width):
|
|
759
|
+
text.append("━", style=self._highlight if start <= i < start + bar else self._track)
|
|
760
|
+
yield text
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
class ProgressCover(Widget):
|
|
764
|
+
"""控件 loading=True 时覆盖其区域的不定进度条(方框 + 居中往复高亮条)。
|
|
765
|
+
|
|
766
|
+
必须是**单个自渲染 widget**:Textual 的 ``Widget._cover()`` 只把它交给合成器
|
|
767
|
+
替换原控件区域,并不挂进 DOM 参与布局;若用容器 + 子件,子件 region 恒为 0
|
|
768
|
+
而完全不可见(``ProgressBar`` 自身 compose 出 ``Bar`` 子件,因此同样不可用)。
|
|
769
|
+
"""
|
|
770
|
+
|
|
771
|
+
COMPONENT_CLASSES = {"pc--bar", "pc--highlight"}
|
|
772
|
+
|
|
773
|
+
DEFAULT_CSS = """
|
|
774
|
+
ProgressCover {
|
|
775
|
+
width: 100%;
|
|
776
|
+
height: 100%;
|
|
777
|
+
background: $surface;
|
|
778
|
+
border: round $secondary;
|
|
779
|
+
margin: 1 1 0 1;
|
|
780
|
+
content-align: center middle;
|
|
781
|
+
&> .pc--bar { color: $panel-lighten-1; background: $surface; }
|
|
782
|
+
&> .pc--highlight { color: $primary; background: $surface; }
|
|
783
|
+
}
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
BAR_WIDTH = 40
|
|
787
|
+
"""进度条固定宽(与全屏 BusyOverlay `.busy-box` 的内容宽一致),由 content-align 居中。"""
|
|
788
|
+
|
|
789
|
+
def __init__(self) -> None:
|
|
790
|
+
super().__init__()
|
|
791
|
+
self._clock = Clock()
|
|
792
|
+
|
|
793
|
+
def on_mount(self) -> None:
|
|
794
|
+
self._clock.reset()
|
|
795
|
+
self.auto_refresh = 1 / 15
|
|
796
|
+
|
|
797
|
+
def render(self) -> _IndeterminateBar:
|
|
798
|
+
highlight = Style.from_color(self.get_component_rich_style("pc--highlight").color)
|
|
799
|
+
track = Style.from_color(self.get_component_rich_style("pc--bar").color)
|
|
800
|
+
return _IndeterminateBar(
|
|
801
|
+
self._clock, self.app.animation_level, highlight, track, self.BAR_WIDTH
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
class BusyOverlay(ModalScreen[None]):
|
|
806
|
+
"""全屏忙碌遮罩:阻断一切输入直到被 pop。
|
|
807
|
+
|
|
808
|
+
使用固定宽度(46)与分行结构防止文案变化引起容器抖动或 Emoji 换行。
|
|
809
|
+
"""
|
|
810
|
+
|
|
811
|
+
DEFAULT_CSS = """
|
|
812
|
+
BusyOverlay {
|
|
813
|
+
align: center middle;
|
|
814
|
+
background: $boost;
|
|
815
|
+
}
|
|
816
|
+
BusyOverlay .busy-box {
|
|
817
|
+
width: 46;
|
|
818
|
+
height: auto;
|
|
819
|
+
min-height: 5;
|
|
820
|
+
padding: 1 2;
|
|
821
|
+
border: round $primary;
|
|
822
|
+
background: $surface;
|
|
823
|
+
align: center middle;
|
|
824
|
+
}
|
|
825
|
+
BusyOverlay #busy-title {
|
|
826
|
+
width: 100%;
|
|
827
|
+
content-align: center middle;
|
|
828
|
+
text-style: bold;
|
|
829
|
+
text-wrap: nowrap;
|
|
830
|
+
text-overflow: ellipsis;
|
|
831
|
+
overflow: hidden;
|
|
832
|
+
margin-bottom: 1;
|
|
833
|
+
}
|
|
834
|
+
BusyOverlay LoadingIndicator { width: 100%; height: 1; }
|
|
835
|
+
BusyOverlay ProgressBar { width: 100%; }
|
|
836
|
+
BusyOverlay #busy-counter {
|
|
837
|
+
width: 100%;
|
|
838
|
+
content-align: center middle;
|
|
839
|
+
color: $text-muted;
|
|
840
|
+
margin-top: 1;
|
|
841
|
+
}
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
def __init__(self, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None) -> None:
|
|
845
|
+
super().__init__()
|
|
846
|
+
self._message = message
|
|
847
|
+
self._mode = mode
|
|
848
|
+
self._total = total
|
|
849
|
+
self._done = 0.0
|
|
850
|
+
|
|
851
|
+
def compose(self):
|
|
852
|
+
with Vertical(classes="busy-box"):
|
|
853
|
+
yield Static(self._message, id="busy-title")
|
|
854
|
+
if self._mode == "bar":
|
|
855
|
+
yield ProgressBar(total=None, show_bar=True, show_percentage=False, show_eta=False)
|
|
856
|
+
elif self._mode == "percent":
|
|
857
|
+
yield ProgressBar(total=self._total, show_percentage=True, show_eta=False)
|
|
858
|
+
yield Static(f"0/{int(self._total or 0)} (0%)", id="busy-counter")
|
|
859
|
+
else:
|
|
860
|
+
yield LoadingIndicator()
|
|
861
|
+
|
|
862
|
+
def set_message(self, message: str) -> None:
|
|
863
|
+
self._message = message
|
|
864
|
+
if self.is_mounted:
|
|
865
|
+
with contextlib.suppress(NoMatches):
|
|
866
|
+
self.query_one("#busy-title", Static).update(message)
|
|
867
|
+
|
|
868
|
+
def advance(self, amount: float = 1) -> None:
|
|
869
|
+
"""percent 模式:推进进度条并刷新计数文案。"""
|
|
870
|
+
self._done += amount
|
|
871
|
+
if self.is_mounted:
|
|
872
|
+
with contextlib.suppress(NoMatches):
|
|
873
|
+
bar = self.query_one(ProgressBar)
|
|
874
|
+
bar.progress += amount
|
|
875
|
+
if self._mode == "percent":
|
|
876
|
+
pct = int(round((self._done / (self._total or 1)) * 100))
|
|
877
|
+
self.query_one("#busy-counter", Static).update(
|
|
878
|
+
f"{int(self._done)}/{int(self._total or 0)} ({pct}%)"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@contextlib.asynccontextmanager
|
|
883
|
+
async def busy(app, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None):
|
|
884
|
+
"""异步 worker 内包裹耗时操作,全程显示全屏忙碌遮罩(异常/取消亦收起)。
|
|
885
|
+
|
|
886
|
+
用法:async with busy(self.app, "正在保存…", mode="bar"): await asyncio.to_thread(...)
|
|
887
|
+
需要进度回调时接住返回的遮罩对象:async with busy(app, "拉取中", mode="percent", total=n) as ov: ...
|
|
888
|
+
"""
|
|
889
|
+
overlay = BusyOverlay(message, mode=mode, total=total)
|
|
890
|
+
app.push_screen(overlay)
|
|
891
|
+
try:
|
|
892
|
+
yield overlay
|
|
893
|
+
finally:
|
|
894
|
+
# 确保遮罩被收起:可能仍是栈顶,也可能已被其它层覆盖
|
|
895
|
+
for _ in range(len(app.screen_stack)):
|
|
896
|
+
if isinstance(app.screen, BusyOverlay) or app.screen is overlay:
|
|
897
|
+
app.pop_screen()
|
|
898
|
+
else:
|
|
899
|
+
break
|
|
900
|
+
if overlay.is_mounted:
|
|
901
|
+
overlay.remove()
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# ---------------------------------------------------------------- 控件就地忙碌态
|
|
905
|
+
|
|
906
|
+
BUSY_MIN_SECONDS = 0.3
|
|
907
|
+
"""忙碌态最短可见时长(秒):本地 API 常在 1 帧内返回,过短的反馈肉眼看不见。"""
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
async def hold_busy(started: float) -> None:
|
|
911
|
+
"""把忙碌反馈补足到 BUSY_MIN_SECONDS,避免快速操作只闪一帧。"""
|
|
912
|
+
remaining = BUSY_MIN_SECONDS - (time.monotonic() - started)
|
|
913
|
+
if remaining > 0:
|
|
914
|
+
await asyncio.sleep(remaining)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
class ControlBusy:
|
|
918
|
+
"""异步操作期间在触发控件上就地反馈:禁用控件 + 文本换成点阵 spinner。
|
|
919
|
+
|
|
920
|
+
- `Button`:改写自身 `label`(如 `⠋ 重启 Clash`),结束后复原。
|
|
921
|
+
- `Select` / `Switch`:禁用控件,并在同行左侧标签(`.kv-label` / `.fl-label`)前插 spinner。
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
def __init__(self, screen) -> None:
|
|
925
|
+
self.screen = screen
|
|
926
|
+
self._control = None
|
|
927
|
+
self._targets: list[tuple[object, str]] = []
|
|
928
|
+
self._frame = 0
|
|
929
|
+
self._timer = None
|
|
930
|
+
|
|
931
|
+
@staticmethod
|
|
932
|
+
def _row_label(control):
|
|
933
|
+
parent = control.parent
|
|
934
|
+
if parent is not None:
|
|
935
|
+
for child in parent.children:
|
|
936
|
+
if isinstance(child, Static):
|
|
937
|
+
return child
|
|
938
|
+
return None
|
|
939
|
+
|
|
940
|
+
def start(self, control) -> None:
|
|
941
|
+
self.stop()
|
|
942
|
+
self._control = control
|
|
943
|
+
control.disabled = True
|
|
944
|
+
if isinstance(control, Button):
|
|
945
|
+
self._targets.append((control, control.label.plain))
|
|
946
|
+
else:
|
|
947
|
+
label = self._row_label(control)
|
|
948
|
+
if label is not None:
|
|
949
|
+
self._targets.append((label, str(label.content)))
|
|
950
|
+
self._apply()
|
|
951
|
+
self._timer = self.screen.set_interval(SPINNERS["dots"]["interval"] / 1000, self._tick)
|
|
952
|
+
|
|
953
|
+
def _apply(self) -> None:
|
|
954
|
+
frames = SPINNERS["dots"]["frames"]
|
|
955
|
+
glyph = frames[self._frame % len(frames)]
|
|
956
|
+
for widget, original in self._targets:
|
|
957
|
+
if isinstance(widget, Button):
|
|
958
|
+
widget.label = f"{glyph} {original}"
|
|
959
|
+
else:
|
|
960
|
+
widget.update(f"{glyph} {original}")
|
|
961
|
+
|
|
962
|
+
def _tick(self) -> None:
|
|
963
|
+
self._frame += 1
|
|
964
|
+
self._apply()
|
|
965
|
+
|
|
966
|
+
def stop(self) -> None:
|
|
967
|
+
if self._timer is not None:
|
|
968
|
+
self._timer.stop()
|
|
969
|
+
self._timer = None
|
|
970
|
+
for widget, original in self._targets:
|
|
971
|
+
if isinstance(widget, Button):
|
|
972
|
+
widget.label = original
|
|
973
|
+
else:
|
|
974
|
+
widget.update(original)
|
|
975
|
+
self._targets = []
|
|
976
|
+
if self._control is not None:
|
|
977
|
+
self._control.disabled = False
|
|
978
|
+
self._control = None
|
|
979
|
+
|
|
980
|
+
|
|
702
981
|
__all__ = [
|
|
703
982
|
"ConfirmModal", "InputModal", "FormField", "FormModal", "OutputModal",
|
|
704
983
|
"ClickTable", "make_table", "load_rows", "filter_fuzzy", "fuzzy_score",
|
|
705
984
|
"shorten", "rcell", "fit_table_columns",
|
|
985
|
+
"ProgressCover", "BusyOverlay", "busy", "ControlBusy", "hold_busy", "BUSY_MIN_SECONDS",
|
|
706
986
|
"tint", "colored_text",
|
|
707
987
|
"STYLE_OK", "STYLE_ERR", "STYLE_WARN", "STYLE_INFO", "STYLE_DIM",
|
|
708
988
|
"HINT_PICK", "HINT_MENU", "HINT_FORM", "EDIT_HINT", "HINT_FORM_EDIT", "HINT_FORM_TA",
|
|
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.2 → clash_controller-0.2.4}/clash_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{clash_controller-0.2.2 → clash_controller-0.2.4}/clash_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|