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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {clash_controller-0.2.6 → clash_controller-0.2.7}/PKG-INFO +9 -1
  2. {clash_controller-0.2.6 → clash_controller-0.2.7}/README.md +8 -0
  3. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/__init__.py +1 -1
  4. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/api.py +38 -13
  5. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/config.py +8 -4
  6. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/deploy.py +50 -13
  7. clash_controller-0.2.7/clash_controller/elevated_helper.py +36 -0
  8. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/main.py +9 -0
  9. clash_controller-0.2.7/clash_controller/platform_utils.py +631 -0
  10. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/config.py +39 -4
  11. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/overview.py +1 -1
  12. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/profiles.py +15 -4
  13. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/ssh.py +75 -29
  14. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/PKG-INFO +9 -1
  15. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/SOURCES.txt +4 -0
  16. {clash_controller-0.2.6 → clash_controller-0.2.7}/pyproject.toml +1 -1
  17. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/__main__.py +0 -0
  18. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/app.py +0 -0
  19. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/app.tcss +0 -0
  20. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/proxies.py +0 -0
  21. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/__init__.py +0 -0
  22. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/home.py +0 -0
  23. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/logs.py +0 -0
  24. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/nodes.py +0 -0
  25. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/screens/settings.py +0 -0
  26. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/ui.py +0 -0
  27. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller/widgets.py +0 -0
  28. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/dependency_links.txt +0 -0
  29. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/entry_points.txt +0 -0
  30. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/requires.txt +0 -0
  31. {clash_controller-0.2.6 → clash_controller-0.2.7}/clash_controller.egg-info/top_level.txt +0 -0
  32. {clash_controller-0.2.6 → clash_controller-0.2.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clash_controller
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: A Textual TUI for managing Clash proxy instances
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -96,6 +96,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
96
96
  clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
97
97
  ```
98
98
 
99
+ ## Windows 说明
100
+
101
+ - Clash 配置目录默认从 `%USERPROFILE%\.config\clash` / `%USERPROFILE%\.config\mihomo` 探测,也可在端点里手动填写(支持 `~`、`%VAR%`)。
102
+ - 若配置目录位于 `Program Files`、`ProgramData` 等受保护位置,部署时会弹出 UAC 请求管理员写入;请在端点设置中保持“管理员写入 (UAC)”开启,并在弹出的对话框中点击“是”。提权后由程序自身的管理员进程完成写入(不借助 PowerShell,避免被安全软件当成攻击行为掐掉)。部署界面会显示当前阶段(请求授权 / 已授权正在写入),详情同时写入“应用日志”页。
103
+ - 写入是否成功以 `config.yaml` 的实际内容为准:只要目标文件已替换为拉取到的配置就判定成功,不会因提权进程未回写确认文件而误报“等待 UAC 超时”。替换走系统 `ReplaceFileW`,会保留 `config.yaml` 原有的访问权限(ACL),原配置备份为同目录的 `config.yaml.bak`。
104
+ - 如果当前进程已经是管理员权限、或账户不属于管理员组,程序不会白弹 UAC,而是直接说明真正的原因(只读属性 / ACL 拒绝 / 文件被占用,或建议把配置目录放到用户目录)。
105
+ - 订阅与本地配置会按 UTF-8 / GB18030 / 带 BOM 的 UTF-16 自动识别,并统一以 UTF-8(无 BOM、LF 换行)写入,不会再把中文变成乱码;控制 API 响应体同样按字节解码,中文节点名不受 `Content-Type` 缺 charset 影响。
106
+
99
107
  ## 开发者安装
100
108
 
101
109
  ```bash
@@ -69,6 +69,14 @@ clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-
69
69
  clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
70
70
  ```
71
71
 
72
+ ## Windows 说明
73
+
74
+ - Clash 配置目录默认从 `%USERPROFILE%\.config\clash` / `%USERPROFILE%\.config\mihomo` 探测,也可在端点里手动填写(支持 `~`、`%VAR%`)。
75
+ - 若配置目录位于 `Program Files`、`ProgramData` 等受保护位置,部署时会弹出 UAC 请求管理员写入;请在端点设置中保持“管理员写入 (UAC)”开启,并在弹出的对话框中点击“是”。提权后由程序自身的管理员进程完成写入(不借助 PowerShell,避免被安全软件当成攻击行为掐掉)。部署界面会显示当前阶段(请求授权 / 已授权正在写入),详情同时写入“应用日志”页。
76
+ - 写入是否成功以 `config.yaml` 的实际内容为准:只要目标文件已替换为拉取到的配置就判定成功,不会因提权进程未回写确认文件而误报“等待 UAC 超时”。替换走系统 `ReplaceFileW`,会保留 `config.yaml` 原有的访问权限(ACL),原配置备份为同目录的 `config.yaml.bak`。
77
+ - 如果当前进程已经是管理员权限、或账户不属于管理员组,程序不会白弹 UAC,而是直接说明真正的原因(只读属性 / ACL 拒绝 / 文件被占用,或建议把配置目录放到用户目录)。
78
+ - 订阅与本地配置会按 UTF-8 / GB18030 / 带 BOM 的 UTF-16 自动识别,并统一以 UTF-8(无 BOM、LF 换行)写入,不会再把中文变成乱码;控制 API 响应体同样按字节解码,中文节点名不受 `Content-Type` 缺 charset 影响。
79
+
72
80
  ## 开发者安装
73
81
 
74
82
  ```bash
@@ -2,4 +2,4 @@
2
2
  Clash Controller - A Textual TUI for managing Clash proxy instances.
3
3
  """
4
4
 
5
- __version__ = "0.2.6"
5
+ __version__ = "0.2.7"
@@ -1,12 +1,39 @@
1
+ import json
1
2
  from urllib.parse import quote
2
3
 
3
4
  import requests
4
5
 
6
+ BODY_ENCODINGS = ("utf-8-sig", "gb18030")
7
+
5
8
 
6
9
  def _noop(_message: str) -> None:
7
10
  pass
8
11
 
9
12
 
13
+ def _decode_body(response) -> str:
14
+ """按字节显式解码响应体。
15
+
16
+ 不要直接用 ``response.text``:中间层(反向代理 / CDN)可能把 Content-Type 改成
17
+ 没有 charset 的 ``text/*``,requests 会强制按 ISO-8859-1 解码,中文节点名直接乱码。
18
+ """
19
+ data = response.content or b""
20
+ for encoding in BODY_ENCODINGS:
21
+ try:
22
+ return data.decode(encoding)
23
+ except UnicodeDecodeError:
24
+ continue
25
+ return data.decode("utf-8", errors="replace")
26
+
27
+
28
+ def _parse_body(response):
29
+ """解析响应体:优先按 JSON,失败则退回解码后的文本。"""
30
+ text = _decode_body(response)
31
+ try:
32
+ return json.loads(text)
33
+ except ValueError:
34
+ return text
35
+
36
+
10
37
  class ClashAPI:
11
38
  # Special sentinel returned when the request was sent but the connection
12
39
  # was interrupted (server closed connection / connection reset) and the
@@ -105,13 +132,10 @@ class ClashAPI:
105
132
  return {"status": "success", "status_code": response.status_code}, None
106
133
 
107
134
  # Try JSON first, fall back to text
108
- try:
109
- parsed = response.json()
110
- except Exception:
111
- parsed = response.text
135
+ parsed = _parse_body(response)
112
136
 
113
137
  if self.debug:
114
- raw = (response.text or '')[:4096]
138
+ raw = _decode_body(response)[:4096]
115
139
  self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
116
140
 
117
141
  # If the parsed result is a dict, inject status_code for caller convenience
@@ -147,9 +171,13 @@ class ClashAPI:
147
171
  return {"status": "success"}, None
148
172
 
149
173
  if self.debug:
150
- raw = response.text[:4096]
174
+ raw = _decode_body(response)[:4096]
151
175
  self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
152
- return response.json(), None
176
+ parsed = _parse_body(response)
177
+ if isinstance(parsed, dict):
178
+ parsed.setdefault('status_code', response.status_code)
179
+ return parsed, None
180
+ return {"body": parsed, "status_code": response.status_code}, None
153
181
 
154
182
  except requests.exceptions.RequestException as exc:
155
183
  if self.debug:
@@ -182,16 +210,13 @@ class ClashAPI:
182
210
  resp = getattr(e, 'response', None)
183
211
  if resp is not None:
184
212
  try:
185
- body = resp.json()
213
+ body = _parse_body(resp)
186
214
  except Exception:
187
- try:
188
- body = resp.text
189
- except Exception:
190
- body = ''
215
+ body = ''
191
216
 
192
217
  if self.debug:
193
218
  try:
194
- self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp.text or '')[:4096]}")
219
+ self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={_decode_body(resp)[:4096]}")
195
220
  except Exception:
196
221
  self._log(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
197
222
 
@@ -31,9 +31,13 @@ def _load_json(path: Path, log: Callable[[str], None]) -> list:
31
31
  if not path.exists():
32
32
  return []
33
33
  try:
34
- with open(path, encoding="utf-8") as f:
35
- data = json.load(f)
36
- except (json.JSONDecodeError, OSError) as e:
34
+ raw = path.read_bytes()
35
+ if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
36
+ text = raw.decode("utf-16")
37
+ else:
38
+ text = raw.decode("utf-8-sig")
39
+ data = json.loads(text)
40
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e:
37
41
  log(f"无法读取或解析配置文件 {path}: {e}")
38
42
  return []
39
43
  return data if isinstance(data, list) else []
@@ -41,7 +45,7 @@ def _load_json(path: Path, log: Callable[[str], None]) -> list:
41
45
 
42
46
  def _save_json(path: Path, data: list) -> Path:
43
47
  path.parent.mkdir(parents=True, exist_ok=True)
44
- with open(path, "w", encoding="utf-8") as f:
48
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
45
49
  json.dump(data, f, indent=4, ensure_ascii=False)
46
50
  return path
47
51
 
@@ -8,8 +8,11 @@ from urllib.parse import urlparse
8
8
 
9
9
  import requests
10
10
 
11
+ from .platform_utils import default_local_config_dir, expand_local_path
11
12
  from .ssh import fetch_remote_file_meta_and_content
12
13
 
14
+ CONFIG_ENCODINGS = ("utf-8-sig", "gb18030")
15
+
13
16
 
14
17
  def _noop(_message: str) -> None:
15
18
  pass
@@ -40,25 +43,59 @@ def default_ssh_host(url: str) -> str:
40
43
  return '127.0.0.1'
41
44
 
42
45
 
43
- def get_remote_last_modified(url: str) -> datetime | None:
44
- """读取远端 URL 的 Last-Modified 头,失败或缺失返回 None。"""
46
+ def decode_config_bytes(data: bytes) -> str:
47
+ """把订阅 / 本地配置字节解码为文本。
48
+
49
+ 不能用 ``response.text``:requests 对没有 charset 的 ``text/*`` 会强制按
50
+ ISO-8859-1 解码,中文会变成乱码。这里显式按 UTF-8(去 BOM)→ GB18030
51
+ 的顺序尝试,覆盖国内机场常见的两种编码;带 BOM 的 UTF-16(Windows 记事本
52
+ “Unicode”格式)也能识别。
53
+ """
54
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
55
+ # Windows 记事本“Unicode”保存的是带 BOM 的 UTF-16。
56
+ try:
57
+ return data.decode("utf-16")
58
+ except UnicodeDecodeError:
59
+ pass
60
+ for encoding in CONFIG_ENCODINGS:
61
+ try:
62
+ return data.decode(encoding)
63
+ except UnicodeDecodeError:
64
+ continue
65
+ raise RuntimeError("配置内容不是有效的 UTF-8 / GB18030 文本,无法部署")
66
+
67
+
68
+ def normalize_config_text(text: str) -> str:
69
+ """统一为 LF 换行,避免 Windows 文本模式写出的 CRLF 造成哈希反复不一致。"""
70
+ return text.replace("\r\n", "\n").replace("\r", "\n")
71
+
72
+
73
+ def canonical_config_bytes(data: bytes) -> bytes:
74
+ """把配置规范化为 UTF-8(无 BOM、LF 换行)字节,用于落盘与哈希比对。"""
75
+ return normalize_config_text(decode_config_bytes(data)).encode("utf-8")
76
+
77
+
78
+ def parse_last_modified(value: str | None) -> datetime | None:
79
+ if not value:
80
+ return None
45
81
  try:
46
- response = requests.head(url, timeout=5)
47
- response.raise_for_status()
48
- last_modified = response.headers.get('Last-Modified')
49
- if last_modified:
50
- return parsedate_to_datetime(last_modified)
51
- except requests.exceptions.RequestException:
82
+ return parsedate_to_datetime(value)
83
+ except (TypeError, ValueError, OverflowError):
52
84
  return None
53
- return None
54
85
 
55
86
 
56
87
  def fetch_remote_config(url: str) -> tuple[str, str, datetime | None]:
57
- """拉取远端配置,返回 (text, sha256, last_modified)。失败抛异常。"""
88
+ """拉取远端配置,返回 (规范化文本, sha256, last_modified)。失败抛异常。
89
+
90
+ 直接读取 ``response.content`` 并显式解码,最后统一换行;哈希基于规范化后的
91
+ UTF-8 字节,保证与本地落盘内容可比。Last-Modified 取自本次 GET 响应,避免
92
+ 额外 HEAD 请求(部分订阅服务器不支持 HEAD)。
93
+ """
58
94
  response = requests.get(url, timeout=30)
59
95
  response.raise_for_status()
60
- text = response.text
61
- return text, compute_sha256(text.encode('utf-8')), get_remote_last_modified(url)
96
+ payload = canonical_config_bytes(response.content)
97
+ text = payload.decode('utf-8')
98
+ return text, compute_sha256(payload), parse_last_modified(response.headers.get('Last-Modified'))
62
99
 
63
100
 
64
101
  def read_current_config(
@@ -70,7 +107,7 @@ def read_current_config(
70
107
  if profile.get('endpoint_type', 'local') == 'remote':
71
108
  return fetch_remote_file_meta_and_content(profile, log=log, debug=debug)
72
109
 
73
- config_dir = profile.get('config_directory', '/etc/clash')
110
+ config_dir = expand_local_path(profile.get('config_directory') or default_local_config_dir())
74
111
  config_path = os.path.join(config_dir, 'config.yaml')
75
112
  if not os.path.exists(config_path):
76
113
  raise RuntimeError(f"local config file not found: {config_path}")
@@ -0,0 +1,36 @@
1
+ """Windows 提权写入 helper:由 ``platform_utils`` 通过 UAC 以管理员令牌再启动本包执行。
2
+
3
+ 职责刻意收窄成「读 payload → 原子写目标 → 回写状态」三件事:不读应用配置、不联网、不碰 UI,
4
+ 所以被提权的进程能做的事非常有限,出错也只可能来自这几个路径。
5
+ """
6
+ import argparse
7
+ import sys
8
+
9
+ from .platform_utils import HELPER_FLAG, elevated_apply_payload, write_status
10
+
11
+ EXIT_OK = 0
12
+ EXIT_FAILED = 1
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog=f"clashctl {HELPER_FLAG}",
18
+ description="以管理员权限写入 Clash 配置文件(由 clashctl 内部调用,无需手动执行)",
19
+ )
20
+ parser.add_argument("payload", help="待写入内容所在的临时文件")
21
+ parser.add_argument("target", help="目标 config.yaml 的绝对路径")
22
+ parser.add_argument("backup", help="备份文件路径")
23
+ parser.add_argument("status", help="回写结果的状态文件路径")
24
+ args = parser.parse_args(argv)
25
+
26
+ try:
27
+ elevated_apply_payload(args.payload, args.target, args.backup)
28
+ except BaseException as exc: # noqa: BLE001 # 提权进程必须把任何失败原因带回主进程
29
+ write_status(args.status, f"ERR {type(exc).__name__}: {exc}")
30
+ return EXIT_FAILED
31
+ write_status(args.status, "OK")
32
+ return EXIT_OK
33
+
34
+
35
+ if __name__ == "__main__":
36
+ sys.exit(main())
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from . import __version__
9
9
  from .config import CONFIG_DIR_ENV, DEFAULT_CONFIG_DIR
10
+ from .platform_utils import HELPER_FLAG
10
11
 
11
12
 
12
13
  def _setup_logging() -> None:
@@ -37,6 +38,14 @@ def parse_args():
37
38
 
38
39
 
39
40
  def main() -> int:
41
+ # 提权 helper:打包成 exe 后 sys.executable 就是本程序,UAC 提权重启时由这里分派;
42
+ # pip 安装则直接走 `python -m clash_controller.elevated_helper`,不经过本函数。
43
+ argv = sys.argv[1:]
44
+ if argv[:1] == [HELPER_FLAG]:
45
+ from .elevated_helper import main as elevated_main
46
+
47
+ return elevated_main(argv[1:])
48
+
40
49
  _setup_logging()
41
50
  args = parse_args()
42
51
  os.environ[CONFIG_DIR_ENV] = args.dir