clash-controller 0.0.1__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.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: clash_controller
3
+ Version: 0.0.1
4
+ Summary: A controller for Clash
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: Bug Tracker, https://github.com/Moha-Master/Clash-Controller/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests
14
+ Requires-Dist: requests_unixsocket
15
+ Requires-Dist: InquirerPy
16
+
17
+ # Clash Controller
18
+
19
+ 一个使用 `InquirerPy` 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器。它可以让您方便地通过命令行管理和监控一个或多个 `clash` 实例。
20
+
21
+ ## 功能特性
22
+
23
+ - **交互式 TUI 界面**: 友好的菜单驱动操作,无需记忆复杂命令。
24
+ - **多端点管理**:
25
+ - 自动保存连接过的 Clash 端点(地址和密钥)。
26
+ - 启动时可从已保存列表中快速选择。
27
+ - 支持添加新的端点。
28
+ - 支持 HTTP 和 Unix Domain Socket 连接。
29
+ - **实时监控面板**:
30
+ - **概览 (Overview)**: 实时显示上/下行流量、内存使用和内核版本。
31
+ - **连接 (Connections)**: 实时展示当前的活动连接列表、总连接数和累计流量。
32
+ - **强大的设置菜单**:
33
+ - **模式切换**: 循环切换 `规则` / `全局` / `直连` 模式,并开关 `TUN` 模式。
34
+ - **重载与重启**: 独立地重载配置文件、GEO 数据库,或重启 Clash 核心。
35
+ - **一键升级**: 在线升级内核、UI 面板和 GEO 数据库。
36
+ - **查看完整配置**: 显示当前 Clash 的全部运行配置。
37
+
38
+ ## 安装
39
+
40
+ ```bash
41
+ pip install clash-controller
42
+ ```
43
+
44
+ ## 使用方法
45
+
46
+ 安装后,可以通过以下命令启动程序:
47
+
48
+ ```bash
49
+ clashctl
50
+ ```
51
+
52
+ 程序启动后,会提示您选择一个已保存的 Clash 端点或添加一个新的端点。
53
+
54
+ ## 开发者安装
55
+
56
+ 如果你想要从源代码运行或者参与开发:
57
+
58
+ ```bash
59
+ git clone https://github.com/Moha-Master/clash-controller.git
60
+ cd clash-controller
61
+
62
+ # 创建虚拟环境 (推荐)
63
+ python -m venv venv
64
+ source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
65
+
66
+ # 安装依赖
67
+ pip install -r requirements.txt
68
+
69
+ # 运行程序
70
+ python -m clash_controller
71
+ ```
72
+
73
+ ## 要求
74
+
75
+ - Python 3.8+
76
+ - 运行中的 Clash 实例,已开启外部控制 API
@@ -0,0 +1,60 @@
1
+ # Clash Controller
2
+
3
+ 一个使用 `InquirerPy` 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器。它可以让您方便地通过命令行管理和监控一个或多个 `clash` 实例。
4
+
5
+ ## 功能特性
6
+
7
+ - **交互式 TUI 界面**: 友好的菜单驱动操作,无需记忆复杂命令。
8
+ - **多端点管理**:
9
+ - 自动保存连接过的 Clash 端点(地址和密钥)。
10
+ - 启动时可从已保存列表中快速选择。
11
+ - 支持添加新的端点。
12
+ - 支持 HTTP 和 Unix Domain Socket 连接。
13
+ - **实时监控面板**:
14
+ - **概览 (Overview)**: 实时显示上/下行流量、内存使用和内核版本。
15
+ - **连接 (Connections)**: 实时展示当前的活动连接列表、总连接数和累计流量。
16
+ - **强大的设置菜单**:
17
+ - **模式切换**: 循环切换 `规则` / `全局` / `直连` 模式,并开关 `TUN` 模式。
18
+ - **重载与重启**: 独立地重载配置文件、GEO 数据库,或重启 Clash 核心。
19
+ - **一键升级**: 在线升级内核、UI 面板和 GEO 数据库。
20
+ - **查看完整配置**: 显示当前 Clash 的全部运行配置。
21
+
22
+ ## 安装
23
+
24
+ ```bash
25
+ pip install clash-controller
26
+ ```
27
+
28
+ ## 使用方法
29
+
30
+ 安装后,可以通过以下命令启动程序:
31
+
32
+ ```bash
33
+ clashctl
34
+ ```
35
+
36
+ 程序启动后,会提示您选择一个已保存的 Clash 端点或添加一个新的端点。
37
+
38
+ ## 开发者安装
39
+
40
+ 如果你想要从源代码运行或者参与开发:
41
+
42
+ ```bash
43
+ git clone https://github.com/Moha-Master/clash-controller.git
44
+ cd clash-controller
45
+
46
+ # 创建虚拟环境 (推荐)
47
+ python -m venv venv
48
+ source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
49
+
50
+ # 安装依赖
51
+ pip install -r requirements.txt
52
+
53
+ # 运行程序
54
+ python -m clash_controller
55
+ ```
56
+
57
+ ## 要求
58
+
59
+ - Python 3.8+
60
+ - 运行中的 Clash 实例,已开启外部控制 API
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Clash Controller - A TUI for managing Clash proxy instances.
4
+ """
5
+
6
+ __version__ = "0.0.1"
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Allow running the CLI with `python -m clash_controller`
4
+ """
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,197 @@
1
+ import requests
2
+ from urllib.parse import quote
3
+
4
+ class ClashAPI:
5
+ def __init__(self, base_url, secret=None, timeout=5, working_directory=None):
6
+ """
7
+ Initializes the Clash API client.
8
+
9
+ :param base_url: The base URL of the Clash controller API.
10
+ (e.g., http://127.0.0.1:9090 or unix:///path/to/socket)
11
+ :param secret: The secret for API authentication.
12
+ :param timeout: Request timeout in seconds.
13
+ :param working_directory: The working directory of the Clash core, used for config paths.
14
+ """
15
+ self.base_url = base_url
16
+ self.timeout = timeout
17
+ self.headers = {}
18
+ self.working_directory = working_directory
19
+ if secret:
20
+ self.headers['Authorization'] = f'Bearer {secret}'
21
+
22
+ if self.base_url.startswith('unix://'):
23
+ import requests_unixsocket
24
+ self.session = requests_unixsocket.Session()
25
+ encoded_path = quote(self.base_url.lstrip('unix://'), safe='')
26
+ self.base_url = f'http+unix://{encoded_path}'
27
+ else:
28
+ self.session = requests.Session()
29
+
30
+ def _request(self, method, endpoint, params=None, json_data=None, stream=False):
31
+ """Helper method to make requests to the API."""
32
+ url = f"{self.base_url}{endpoint}"
33
+ try:
34
+ response = self.session.request(
35
+ method,
36
+ url,
37
+ headers=self.headers,
38
+ params=params,
39
+ json=json_data,
40
+ timeout=self.timeout,
41
+ stream=stream
42
+ )
43
+ response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
44
+
45
+ # For successful responses, return JSON if content exists, otherwise return a success indicator
46
+ if response.status_code == 204 or not response.content:
47
+ return {"status": "success"}, None
48
+ return response.json(), None
49
+ except requests.exceptions.RequestException as e:
50
+ return None, str(e)
51
+
52
+ # === Real-time Data ===
53
+ def get_logs_stream(self):
54
+ """Get real-time logs. Returns a streaming response object for continuous reading."""
55
+ return self._request('GET', '/logs', stream=True)
56
+
57
+ def get_traffic_stream(self):
58
+ """Get real-time traffic. Returns a streaming response object for continuous reading."""
59
+ return self._request('GET', '/traffic', stream=True)
60
+
61
+ def get_memory_stream(self):
62
+ """Get real-time memory usage. Returns a streaming response object for continuous reading."""
63
+ return self._request('GET', '/memory', stream=True)
64
+
65
+ # === General Info & Control ===
66
+ def get_version(self):
67
+ """Get Clash version."""
68
+ return self._request('GET', '/version')
69
+
70
+ def flush_fake_ip_cache(self):
71
+ """Flush the fake-ip cache."""
72
+ return self._request('POST', '/cache/fakeip/flush')
73
+
74
+ def restart(self, path="", payload=""):
75
+ """Restart Clash core."""
76
+ return self._request('POST', '/restart', json_data={"path": path, "payload": payload} or {})
77
+
78
+ # === Configs ===
79
+ def get_configs(self):
80
+ """Get current configurations."""
81
+ return self._request('GET', '/configs')
82
+
83
+ def update_configs(self, partial_configs: dict):
84
+ """Update configurations with a partial config."""
85
+ return self._request('PATCH', '/configs', json_data=partial_configs)
86
+
87
+ def reload_configs(self, path="", payload=""):
88
+ """Reload configuration from path."""
89
+ return self._request('PUT', '/configs', params={'force': 'true'}, json_data={"path": path, "payload": payload} or {})
90
+
91
+ def set_mode(self, mode: str):
92
+ """Sets the connection mode ('rule', 'global', 'direct')."""
93
+ return self.update_configs({"mode": mode.lower()})
94
+
95
+ def toggle_tun(self, enable: bool):
96
+ """Enable or disable TUN mode."""
97
+ return self.update_configs({"tun": {"enable": enable}})
98
+
99
+ # === Upgrade ===
100
+ def upgrade_kernel(self):
101
+ """Request to upgrade the Clash kernel."""
102
+ return self._request('POST', '/upgrade', json_data={})
103
+
104
+ def upgrade_ui(self):
105
+ """Request to upgrade the external-ui."""
106
+ return self._request('POST', '/upgrade/ui', json_data={})
107
+
108
+ def upgrade_geo_databases(self):
109
+ """Request to upgrade GEO databases from remote."""
110
+ return self._request('POST', '/upgrade/geo', json_data={})
111
+
112
+ def reload_geo_databases(self):
113
+ """Request to reload local GEO databases."""
114
+ return self._request('POST', '/configs/geo', json_data={})
115
+
116
+ # === Proxies & Groups ===
117
+ def get_proxies(self):
118
+ """Get all proxies and groups information."""
119
+ return self._request('GET', '/proxies')
120
+
121
+ def get_proxy(self, name: str):
122
+ """Get a specific proxy or group's information."""
123
+ return self._request('GET', f'/proxies/{quote(name)}')
124
+
125
+ def select_proxy_in_group(self, group_name: str, proxy_name: str):
126
+ """Select a proxy for a specific group."""
127
+ return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
128
+
129
+ def test_proxy_delay(self, name: str, url: str, timeout: int):
130
+ """Test a proxy's delay."""
131
+ params = {'url': url, 'timeout': str(timeout)}
132
+ return self._request('GET', f'/proxies/{quote(name)}/delay', params=params)
133
+
134
+ def get_groups(self):
135
+ """Get policy groups."""
136
+ return self._request('GET', '/group')
137
+
138
+ def get_group(self, name: str):
139
+ """Get a specific policy group."""
140
+ return self._request('GET', f'/group/{quote(name)}')
141
+
142
+ def reset_auto_group_selection(self, name: str):
143
+ """Reset the fixed selection of an auto policy group."""
144
+ return self._request('DELETE', f'/group/{quote(name)}')
145
+
146
+ def test_group_delay(self, name: str, url: str, timeout: int):
147
+ """Test the delay of all proxies in a group."""
148
+ params = {'url': url, 'timeout': str(timeout)}
149
+ return self._request('GET', f'/group/{quote(name)}/delay', params=params)
150
+
151
+ # === Providers ===
152
+ def get_proxy_providers(self):
153
+ """Get all proxy providers."""
154
+ return self._request('GET', '/providers/proxies')
155
+
156
+ def get_proxy_provider(self, name: str):
157
+ """Get a specific proxy provider."""
158
+ return self._request('GET', f'/providers/proxies/{quote(name)}')
159
+
160
+ def update_proxy_provider(self, name: str):
161
+ """Update a proxy provider."""
162
+ return self._request('PUT', f'/providers/proxies/{quote(name)}')
163
+
164
+ def healthcheck_proxy_provider(self, name: str):
165
+ """Trigger a health check for a proxy provider."""
166
+ return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
167
+
168
+ def get_rule_providers(self):
169
+ """Get all rule providers."""
170
+ return self._request('GET', '/providers/rules')
171
+
172
+ def update_rule_provider(self, name: str):
173
+ """Update a rule provider."""
174
+ return self._request('PUT', f'/providers/rules/{quote(name)}')
175
+
176
+ # === Rules ===
177
+ def get_rules(self):
178
+ """Get all rules."""
179
+ return self._request('GET', '/rules')
180
+
181
+ # === Connections ===
182
+ def get_connections(self):
183
+ """Get active connections."""
184
+ return self._request('GET', '/connections')
185
+
186
+ def close_all_connections(self):
187
+ """Close all active connections."""
188
+ return self._request('DELETE', '/connections')
189
+
190
+ def close_connection(self, conn_id: str):
191
+ """Close a specific connection by its ID."""
192
+ return self._request('DELETE', f'/connections/{quote(conn_id)}')
193
+
194
+ # === DNS ===
195
+ def query_dns(self, name: str, query_type: str = 'A'):
196
+ """Query DNS."""
197
+ return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
@@ -0,0 +1,771 @@
1
+ import os
2
+ import json
3
+ import sys
4
+ import time
5
+ import threading
6
+ import queue
7
+ from InquirerPy import inquirer
8
+ from InquirerPy.validator import EmptyInputValidator
9
+ from InquirerPy.base.control import Choice, Separator
10
+ import requests # Need to import for requests.exceptions.RequestException
11
+ import os.path
12
+ from datetime import datetime, timezone
13
+ from email.utils import parsedate_to_datetime
14
+
15
+ from .api import ClashAPI
16
+
17
+ # ANSI Color Codes
18
+ COLOR_GREEN = '\033[92m'
19
+ COLOR_YELLOW = '\033[93m'
20
+ COLOR_RED = '\033[91m'
21
+ COLOR_RESET = '\033[0m'
22
+
23
+ # Path for storing connection profiles in the user's home directory
24
+ PROFILE_PATH = os.path.expanduser("~/.config/clash-controller/profiles.json")
25
+ CONFIG_PROVIDERS_PATH = os.path.expanduser("~/.config/clash-controller/config_providers.json")
26
+ TEMP_CONFIG_DIR = os.path.expanduser("~/.config/clash-controller/temp_configs")
27
+
28
+ def get_remote_last_modified(url: str) -> datetime or None:
29
+ """Fetches the Last-Modified header from a remote URL."""
30
+ try:
31
+ response = requests.head(url, timeout=5) # Use HEAD request to get headers only
32
+ response.raise_for_status()
33
+ last_modified = response.headers.get('Last-Modified')
34
+ if last_modified:
35
+ return parsedate_to_datetime(last_modified)
36
+ except requests.exceptions.RequestException as e:
37
+ add_log(f"Error fetching Last-Modified for {url}: {e}")
38
+ return None
39
+
40
+ # Global list to store application logs
41
+ app_logs = []
42
+
43
+ def is_local_api(api: ClashAPI) -> bool:
44
+ """Checks if the API base URL points to a local address."""
45
+ if not api or not api.base_url:
46
+ return False
47
+
48
+ # Extract hostname from URL
49
+ try:
50
+ from urllib.parse import urlparse
51
+ parsed_url = urlparse(api.base_url)
52
+ hostname = parsed_url.hostname
53
+ except ImportError:
54
+ # Fallback for older Python versions or if urlparse is not available
55
+ # This is a simplified check and might not cover all edge cases
56
+ if "127.0.0.1" in api.base_url or "localhost" in api.base_url or "::1" in api.base_url:
57
+ return True
58
+ return False
59
+
60
+ if hostname in ["127.0.0.1", "localhost", "::1"]:
61
+ return True
62
+ return False
63
+
64
+ def add_log(message: str):
65
+ """Adds a timestamped message to the application log."""
66
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
67
+ app_logs.append(f"[{timestamp}] {message}")
68
+
69
+ def show_logs_screen():
70
+ """Displays the accumulated application logs."""
71
+ os.system('cls' if os.name == 'nt' else 'clear')
72
+ print("--- Application Logs ---")
73
+ print("-" * 80)
74
+ if not app_logs:
75
+ print("No logs yet.")
76
+ else:
77
+ for log_entry in app_logs:
78
+ print(log_entry)
79
+ print("-" * 80)
80
+ input("Press Enter to return to the settings menu...")
81
+
82
+ def load_profiles():
83
+ """Loads connection profiles from the config file."""
84
+ if not os.path.exists(PROFILE_PATH):
85
+ return []
86
+ try:
87
+ with open(PROFILE_PATH, 'r', encoding='utf-8') as f:
88
+ return json.load(f)
89
+ except (json.JSONDecodeError, IOError):
90
+ print(f"Warning: Could not read or parse profiles file at {PROFILE_PATH}")
91
+ return []
92
+
93
+ def save_profiles(profiles):
94
+ """Saves connection profiles to the config file."""
95
+ try:
96
+ os.makedirs(os.path.dirname(PROFILE_PATH), exist_ok=True)
97
+ with open(PROFILE_PATH, 'w', encoding='utf-8') as f:
98
+ json.dump(profiles, f, indent=4, ensure_ascii=False)
99
+ except IOError as e:
100
+ print(f"Error saving profiles to {PROFILE_PATH}: {e}")
101
+
102
+ def load_config_providers():
103
+ """Loads config provider URLs from the config file."""
104
+ if not os.path.exists(CONFIG_PROVIDERS_PATH):
105
+ return []
106
+ try:
107
+ with open(CONFIG_PROVIDERS_PATH, 'r', encoding='utf-8') as f:
108
+ return json.load(f)
109
+ except (json.JSONDecodeError, IOError):
110
+ print(f"Warning: Could not read or parse config providers file at {CONFIG_PROVIDERS_PATH}")
111
+ return []
112
+
113
+ def save_config_providers(providers):
114
+ """Saves config provider URLs to the config file."""
115
+ try:
116
+ os.makedirs(os.path.dirname(CONFIG_PROVIDERS_PATH), exist_ok=True)
117
+ with open(CONFIG_PROVIDERS_PATH, 'w', encoding='utf-8') as f:
118
+ json.dump(providers, f, indent=4, ensure_ascii=False)
119
+ except IOError as e:
120
+ print(f"Error saving config providers to {CONFIG_PROVIDERS_PATH}: {e}")
121
+
122
+ def _stream_fetcher(api_method, data_queue, stop_event):
123
+ """
124
+ A worker function to run in a thread.
125
+ It fetches data from a streaming API endpoint and puts it into a queue.
126
+ """
127
+ try:
128
+ response, error = api_method() # API now returns (data, error)
129
+ if error:
130
+ add_log(f"Stream fetcher error: {error}")
131
+ data_queue.put(None) # Signal stream end due to error
132
+ return
133
+
134
+ if response:
135
+ for line in response.iter_lines():
136
+ if stop_event.is_set():
137
+ break
138
+ if line:
139
+ try:
140
+ json_str = line.decode('utf-8').lstrip('data: ')
141
+ if json_str:
142
+ data_queue.put(json.loads(json_str))
143
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
144
+ add_log(f"Malformed stream line: {line.decode('utf-8', errors='ignore')} - Error: {e}")
145
+ continue # Ignore malformed lines
146
+ except requests.exceptions.RequestException as e:
147
+ add_log(f"Stream connection error: {e}")
148
+ pass
149
+ finally:
150
+ # Signal that this stream has ended, e.g., for error display
151
+ data_queue.put(None)
152
+
153
+ def show_connections_page(api: ClashAPI):
154
+ """Displays active connections, refreshing periodically."""
155
+ try:
156
+ while True:
157
+ os.system('cls' if os.name == 'nt' else 'clear')
158
+ print("Active Connections (Press Ctrl+C to return)")
159
+ print("-" * 80)
160
+
161
+ connections_data, error = api.get_connections()
162
+ if error:
163
+ print(f"Error retrieving connections: {error}")
164
+ add_log(f"Error retrieving connections: {error}")
165
+ time.sleep(2) # Give user time to read error
166
+ break # Exit connections page on error
167
+
168
+ if connections_data and 'connections' in connections_data:
169
+ connections = connections_data['connections']
170
+ total_dl = connections_data.get('downloadTotal', 0) / (1024*1024)
171
+ total_ul = connections_data.get('uploadTotal', 0) / (1024*1024)
172
+
173
+ print(f"Total Connections: {len(connections)} | Total UL/DL: {total_ul:.2f}MB / {total_dl:.2f}MB")
174
+ print("-" * 80)
175
+
176
+ # Header
177
+ print(f"{'Host':<30} {'Network':<7} {'Type':<10} {'Rule':<12} {'Chains'}")
178
+ print(f"{'-'*30:<30} {'-'*7:<7} {'-'*10:<10} {'-'*12:<12} {'-'*15}")
179
+
180
+ # Display first 20 connections to avoid clutter
181
+ for conn in connections[:20]:
182
+ metadata = conn.get('metadata', {})
183
+ host = metadata.get('host') or metadata.get('destinationIP', 'N/A')
184
+ network = metadata.get('network', 'N/A')
185
+ conn_type = metadata.get('type', 'N/A')
186
+ rule = conn.get('rule', 'N/A')
187
+ chains = " -> ".join(conn.get('chains', []))
188
+
189
+ # Truncate long hostnames
190
+ if len(host) > 28:
191
+ host = host[:25] + "..."
192
+
193
+ print(f"{host:<30} {network:<7} {conn_type:<10} {rule:<12} {chains}")
194
+
195
+ if len(connections) > 20:
196
+ print(f"\n... and {len(connections) - 20} more connections.")
197
+
198
+ else:
199
+ print("Could not retrieve connections or no active connections.")
200
+
201
+ print("-" * 80)
202
+ print(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
203
+
204
+ time.sleep(1) # Refresh interval
205
+
206
+ except KeyboardInterrupt:
207
+ print("\nReturning to main menu...")
208
+ time.sleep(0.5)
209
+
210
+ def show_overview_page(api: ClashAPI):
211
+ """Displays the overview page with real-time stats using streaming."""
212
+ version_info, error = api.get_version()
213
+ version = version_info.get('version', 'N/A') if version_info else 'N/A'
214
+ if error:
215
+ add_log(f"Error fetching version for overview: {error}")
216
+ version = f"N/A (Error: {error})"
217
+
218
+ stop_event = threading.Event()
219
+ traffic_queue = queue.Queue()
220
+ memory_queue = queue.Queue()
221
+
222
+ traffic_thread = threading.Thread(
223
+ target=_stream_fetcher, args=(api.get_traffic_stream, traffic_queue, stop_event), daemon=True
224
+ )
225
+ memory_thread = threading.Thread(
226
+ target=_stream_fetcher, args=(api.get_memory_stream, memory_queue, stop_event), daemon=True
227
+ )
228
+
229
+ traffic_thread.start()
230
+ memory_thread.start()
231
+
232
+ latest_traffic = {"up": 0, "down": 0}
233
+ latest_memory = {"inuse": 0}
234
+ streams_alive = True
235
+
236
+ try:
237
+ while streams_alive:
238
+ # Check for new traffic data
239
+ try:
240
+ traffic_data = traffic_queue.get_nowait()
241
+ if traffic_data is None:
242
+ streams_alive = False
243
+ break
244
+ latest_traffic = traffic_data
245
+ except queue.Empty:
246
+ pass
247
+
248
+ # Check for new memory data
249
+ try:
250
+ memory_data = memory_queue.get_nowait()
251
+ if memory_data is None:
252
+ streams_alive = False
253
+ break
254
+ latest_memory = memory_data
255
+ except queue.Empty:
256
+ pass
257
+
258
+ # --- Render UI ---
259
+ os.system('cls' if os.name == 'nt' else 'clear')
260
+ print("Clash Overview (Press Ctrl+C to go back to Main Menu)")
261
+ print("-" * 50)
262
+ print(f" Version: {version}")
263
+ print("-" * 50)
264
+
265
+ # Display Traffic
266
+ up_kbs = latest_traffic.get('up', 0) / 1024
267
+ down_kbs = latest_traffic.get('down', 0) / 1024
268
+ print(" Traffic:")
269
+ print(f" Upload: {up_kbs:.2f} KB/s")
270
+ print(f" Download: {down_kbs:.2f} KB/s")
271
+
272
+ # Display Memory
273
+ mem_mb = latest_memory.get('inuse', 0) / (1024 * 1024)
274
+ print("\n Memory:")
275
+ print(f" In Use: {mem_mb:.2f} MB")
276
+
277
+ print("-" * 50)
278
+ print(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
279
+
280
+ time.sleep(0.5) # Refresh rate for the screen
281
+
282
+ if not streams_alive:
283
+ print("\nConnection to a real-time data stream was lost.")
284
+ add_log("Real-time data stream lost.")
285
+ input("Press Enter to return to the main menu...")
286
+
287
+ except KeyboardInterrupt:
288
+ pass # User requested to go back
289
+ finally:
290
+ # --- Cleanup ---
291
+ stop_event.set() # Tell threads to stop
292
+ # The threads are daemons, they will exit anyway, but this is cleaner.
293
+ print("\nReturning to main menu...")
294
+ time.sleep(0.5) # Give a moment for the message to be seen
295
+
296
+ def show_config_menu(api: ClashAPI):
297
+ """Displays the configuration sub-menu and handles user actions."""
298
+ if not is_local_api(api):
299
+ print(f"{COLOR_RED}\nConfiguration management is only available for local Clash instances (e.g., 127.0.0.1, localhost).{COLOR_RESET}")
300
+ add_log("Attempted to access Configuration menu on a remote Clash instance.")
301
+ input("Press Enter to return to the main menu...")
302
+ return None
303
+
304
+ while True:
305
+
306
+ try:
307
+ config_providers = load_config_providers()
308
+
309
+ provider_choices = [
310
+ Choice(name=f"{p['name']} ({p['url']})", value=p) for p in config_providers
311
+ ]
312
+ provider_choices.extend([
313
+ Separator(),
314
+ Choice(name="Add new config provider", value="new"),
315
+ Choice(name="Reload Config File (Local)", value="reload_local"),
316
+ Separator(),
317
+ Choice(name="Back to Main Menu", value="back"),
318
+ ])
319
+
320
+ action = inquirer.select(
321
+ message="Configuration Menu",
322
+ choices=provider_choices,
323
+ default=None,
324
+ ).execute()
325
+
326
+ if action == "new":
327
+ try:
328
+ url = inquirer.text(
329
+ message="Enter config file URL (e.g., http://example.com/config.yaml):",
330
+ validate=EmptyInputValidator()
331
+ ).execute()
332
+ provider_name = inquirer.text(
333
+ message="Enter a name for this config provider:",
334
+ default=url,
335
+ validate=EmptyInputValidator()
336
+ ).execute()
337
+ except KeyboardInterrupt:
338
+ add_log("New config provider creation cancelled by user (KeyboardInterrupt).")
339
+ continue
340
+
341
+ new_provider = {"name": provider_name, "url": url}
342
+ config_providers.append(new_provider)
343
+ save_config_providers(config_providers)
344
+ add_log(f"New config provider '{provider_name}' added.")
345
+ print(f"New config provider '{provider_name}' added.")
346
+
347
+ elif action == "reload_local":
348
+ print("\nReloading config file from local storage...")
349
+ _, error = api.reload_configs()
350
+ if not error:
351
+ print("Successfully reloaded config file from local storage.")
352
+ add_log("Successfully reloaded config file from local storage.")
353
+ else:
354
+ print(f"Failed to reload config file from local storage: {error}")
355
+ add_log(f"Failed to reload config file from local storage: {error}")
356
+
357
+ elif action == "back":
358
+ return None
359
+ elif action: # A saved config provider was selected
360
+ provider_url = action['url']
361
+ # Use the working_directory from the active API instance
362
+ download_path = api.working_directory
363
+ if not download_path:
364
+ print("Error: Clash working directory not set for the current profile. Please set it in the main menu.")
365
+ add_log("Error: Clash working directory not set for the current profile.")
366
+ input("Press Enter to continue...")
367
+ continue
368
+
369
+ print(f"Fetching config from {provider_url} to {download_path}...")
370
+ add_log(f"Fetching config from {provider_url} to {download_path}...")
371
+
372
+ target_download_path = download_path
373
+ config_file_name = "config.yaml"
374
+ backup_file_name = "config.yaml.bak"
375
+ config_full_path = os.path.join(target_download_path, config_file_name)
376
+ backup_full_path = os.path.join(target_download_path, backup_file_name)
377
+
378
+ # Get remote and local modification times
379
+ remote_mod_time = get_remote_last_modified(provider_url)
380
+ local_mod_time = None
381
+ if os.path.exists(config_full_path):
382
+ local_mod_time = datetime.fromtimestamp(os.path.getmtime(config_full_path), tz=timezone.utc)
383
+
384
+ proceed_download = True
385
+ if remote_mod_time and local_mod_time:
386
+ if remote_mod_time > local_mod_time:
387
+ print(f"{COLOR_GREEN}Remote config is NEWER ({remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}) than local ({local_mod_time.strftime('%Y-%m-%d %H:%M:%S')}){COLOR_RESET}")
388
+ elif remote_mod_time < local_mod_time:
389
+ print(f"{COLOR_RED}Remote config is OLDER ({remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}) than local ({local_mod_time.strftime('%Y-%m-%d %H:%M:%S')}){COLOR_RESET}")
390
+ confirm = inquirer.confirm(
391
+ message="Remote config is older. Do you still want to download and overwrite?",
392
+ default=False
393
+ ).execute()
394
+ if not confirm:
395
+ proceed_download = False
396
+ print("Download cancelled by user.")
397
+ else:
398
+ print(f"{COLOR_YELLOW}Remote config is the SAME as local ({remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}){COLOR_RESET}")
399
+ proceed_download = False
400
+ print("Skipping download as remote config is identical.")
401
+ elif remote_mod_time:
402
+ print(f"Remote config last modified: {remote_mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
403
+ else:
404
+ print("Could not retrieve remote Last-Modified time. Proceeding with download.")
405
+
406
+ if not proceed_download:
407
+ input("Press Enter to continue...")
408
+ continue
409
+
410
+ try:
411
+ os.makedirs(target_download_path, exist_ok=True)
412
+
413
+ # Check if the target directory is writable
414
+ if not os.access(target_download_path, os.W_OK):
415
+ print(f"Warning: Directory {target_download_path} is not writable. Attempting to save to a temporary location.")
416
+ add_log(f"Warning: Directory {target_download_path} is not writable. Saving to temporary location.")
417
+ os.makedirs(TEMP_CONFIG_DIR, exist_ok=True)
418
+ target_download_path = TEMP_CONFIG_DIR
419
+ config_full_path = os.path.join(target_download_path, config_file_name)
420
+ backup_full_path = os.path.join(target_download_path, backup_file_name)
421
+
422
+ response = requests.get(provider_url, stream=True)
423
+ response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
424
+
425
+ # Backup existing config.yaml if it exists
426
+ if os.path.exists(config_full_path):
427
+ os.replace(config_full_path, backup_full_path)
428
+ print(f"Backed up existing {config_file_name} to {backup_file_name}")
429
+ add_log(f"Backed up existing {config_file_name} to {backup_file_name}")
430
+
431
+ # Save the new config
432
+ with open(config_full_path, 'wb') as f:
433
+ for chunk in response.iter_content(chunk_size=8192):
434
+ f.write(chunk)
435
+ print(f"Successfully downloaded and saved config to {config_full_path}")
436
+ add_log(f"Successfully downloaded and saved config to {config_full_path}")
437
+
438
+ if target_download_path == TEMP_CONFIG_DIR:
439
+ print("\n--- IMPORTANT ---")
440
+ print("The config was saved to a temporary location due to permissions:")
441
+ print(f" {config_full_path}")
442
+ print(f"Please manually move it to your intended Clash working directory: {os.path.join(download_path, config_file_name)}")
443
+ print("You might need to use 'sudo' for this, e.g.:\n")
444
+ print(f"sudo mv {config_full_path} {os.path.join(download_path, config_file_name)}")
445
+ print("After moving, you can reload the config via the 'Reload Config File (Local)' option in this menu.")
446
+ add_log("Config saved to temporary location due to permissions. User instructed to move manually.")
447
+ else:
448
+ # Reload config via API only if saved to the intended location
449
+ print("Reloading config file via Clash API...")
450
+ _, error = api.reload_configs(path=config_full_path)
451
+ if not error:
452
+ print("Successfully reloaded config file via Clash API.")
453
+ add_log("Successfully reloaded config file via Clash API.")
454
+ else:
455
+ print(f"Failed to reload config file via Clash API: {error}")
456
+ add_log(f"Failed to reload config file via Clash API: {error}")
457
+
458
+ except requests.exceptions.RequestException as e:
459
+ print(f"Error fetching config: {e}")
460
+ add_log(f"Error fetching config from {provider_url}: {e}")
461
+ except IOError as e:
462
+ print(f"Error saving config file: {e}")
463
+ add_log(f"Error saving config file: {e}")
464
+ except Exception as e:
465
+ print(f"An unexpected error occurred during config download/save: {e}")
466
+ add_log(f"Unexpected error during config download/save: {e}")
467
+ input("Press Enter to continue...")
468
+
469
+ except KeyboardInterrupt:
470
+ add_log("Configuration menu exited by user (KeyboardInterrupt).")
471
+ return None
472
+
473
+ def show_settings_menu(api: ClashAPI):
474
+
475
+ """Displays the settings sub-menu and handles user actions."""
476
+ while True:
477
+ try:
478
+ current_configs, error = api.get_configs()
479
+ if error:
480
+ print(f"Error: Could not fetch settings: {error}. Going back to main menu.")
481
+ add_log(f"Error: Could not fetch settings: {error}.")
482
+ return None
483
+
484
+ tun_enabled = current_configs.get('tun', {}).get('enable', False)
485
+ tun_status_str = "ON" if tun_enabled else "OFF"
486
+ current_mode = current_configs.get('mode', 'N/A').capitalize()
487
+
488
+ action = inquirer.select(
489
+ message="Settings",
490
+ choices=[
491
+ Choice(name=f"Toggle TUN Mode (Current: {tun_status_str})", value="toggle_tun"),
492
+ Choice(name=f"Switch Mode (Current: {current_mode})", value="switch_mode"),
493
+ Separator(),
494
+ # Section 2: Reload & Restart
495
+ Choice(name="Reload GEO Databases", value="reload_geo"),
496
+ Choice(name="Restart Clash Core", value="restart"),
497
+ Separator(),
498
+ # Section 3: Upgrade
499
+ Choice(name="Upgrade Kernel", value="upgrade_kernel"),
500
+ Choice(name="Upgrade UI", value="upgrade_ui"),
501
+ Choice(name="Upgrade GEO Databases", value="upgrade_geo"),
502
+ Separator(),
503
+ # Section 4: Endpoint Management
504
+ Choice(name="Switch Endpoint", value="switch_endpoint"),
505
+ Choice(name="View Logs", value="view_logs"), # New option
506
+ Separator(),
507
+ Choice(name="Back to Main Menu", value="back"),
508
+ ],
509
+ ).execute()
510
+
511
+ if action == "toggle_tun":
512
+ new_state = not tun_enabled
513
+ _, error = api.toggle_tun(new_state)
514
+ if not error:
515
+ print(f"Successfully {'enabled' if new_state else 'disabled'} TUN mode.")
516
+ add_log(f"Successfully {'enabled' if new_state else 'disabled'} TUN mode.")
517
+ else:
518
+ print(f"Failed to toggle TUN mode: {error}")
519
+ add_log(f"Failed to toggle TUN mode: {error}")
520
+ elif action == "switch_mode":
521
+ modes = ['rule', 'global', 'direct']
522
+ current_mode_lower = current_configs.get('mode', 'rule')
523
+ try:
524
+ current_index = modes.index(current_mode_lower)
525
+ next_index = (current_index + 1) % len(modes)
526
+ next_mode = modes[next_index]
527
+ except ValueError:
528
+ next_mode = 'rule' # Default if current mode is not in list
529
+ _, error = api.set_mode(next_mode)
530
+ if not error:
531
+ print(f"Successfully switched mode to {next_mode.capitalize()}.")
532
+ add_log(f"Successfully switched mode to {next_mode.capitalize()}.")
533
+ else:
534
+ print(f"Failed to switch mode to {next_mode.capitalize()}: {error}")
535
+ add_log(f"Failed to switch mode to {next_mode.capitalize()}: {error}")
536
+ elif action == "reload_geo":
537
+ print("\nRequesting GEO databases reload...")
538
+ _, error = api.reload_geo_databases()
539
+ if not error:
540
+ print("Successfully requested GEO databases reload.")
541
+ add_log("Successfully requested GEO databases reload.")
542
+ else:
543
+ print(f"Failed to request GEO databases reload: {error}")
544
+ add_log(f"Failed to request GEO databases reload: {error}")
545
+ elif action == "restart":
546
+ print("\nRestarting Clash Core...")
547
+ _, error = api.restart()
548
+ if not error:
549
+ print("Successfully restarted Clash Core.")
550
+ add_log("Successfully restarted Clash Core.")
551
+ else:
552
+ print(f"Failed to restart Clash Core: {error}")
553
+ add_log(f"Failed to restart Clash Core: {error}")
554
+ elif action == "upgrade_kernel":
555
+ print("\nRequesting Kernel upgrade...")
556
+ _, error = api.upgrade_kernel()
557
+ if not error:
558
+ print("Successfully requested Kernel upgrade. Check logs for details.")
559
+ add_log("Successfully requested Kernel upgrade.")
560
+ else:
561
+ print(f"Failed to request Kernel upgrade: {error}")
562
+ add_log(f"Failed to request Kernel upgrade: {error}")
563
+ elif action == "upgrade_ui":
564
+ print("\nRequesting UI upgrade...")
565
+ _, error = api.upgrade_ui()
566
+ if not error:
567
+ print("Successfully requested UI upgrade. Check logs for details.")
568
+ add_log("Successfully requested UI upgrade.")
569
+ else:
570
+ print(f"Failed to request UI upgrade: {error}")
571
+ add_log(f"Failed to request UI upgrade: {error}")
572
+ elif action == "upgrade_geo":
573
+ print("\nRequesting GEO databases upgrade...")
574
+ _, error = api.upgrade_geo_databases()
575
+ if not error:
576
+ print("Successfully requested GEO databases upgrade. Check logs for details.")
577
+ add_log("Successfully requested GEO databases upgrade.")
578
+ else:
579
+ print(f"Failed to request GEO databases upgrade: {error}")
580
+ add_log(f"Failed to request GEO databases upgrade: {error}")
581
+ elif action == "switch_endpoint":
582
+ return "switch_endpoint"
583
+ elif action == "view_logs": # Handle new logs option
584
+ show_logs_screen()
585
+ elif action == "back":
586
+ return None
587
+ except KeyboardInterrupt:
588
+ add_log("Settings menu exited by user (KeyboardInterrupt).")
589
+ return None
590
+
591
+ def show_main_menu(api: ClashAPI):
592
+ """Displays the main menu and handles user actions."""
593
+ version_info, error = api.get_version()
594
+ version = version_info.get('version', 'unknown') if version_info else 'unknown'
595
+ if error:
596
+ add_log(f"Error fetching version for main menu: {error}")
597
+ version = f"N/A (Error: {error})"
598
+
599
+ print(f"\nSuccessfully connected to Clash (version: {version})!")
600
+ add_log(f"Successfully connected to Clash (version: {version}).")
601
+
602
+ while True:
603
+ try:
604
+ choices_list = [
605
+ Choice(name="Overview", value="overview"),
606
+ Choice(name="Connections", value="connections"),
607
+ ]
608
+
609
+ if is_local_api(api):
610
+ choices_list.append(Choice(name="Configuration", value="configuration"))
611
+ else:
612
+ choices_list.append(Choice(name="Configuration (Local Only)", value="configuration_disabled", enabled=False))
613
+
614
+ choices_list.extend([
615
+ Choice(name="Settings", value="settings"),
616
+ Choice(name="Exit", value="exit")
617
+ ])
618
+
619
+ action = inquirer.select(
620
+ message="Main Menu",
621
+ choices=choices_list,
622
+ default=None,
623
+ ).execute()
624
+
625
+ if action == "overview":
626
+ show_overview_page(api)
627
+ elif action == "connections":
628
+ show_connections_page(api)
629
+ elif action == "configuration":
630
+ show_config_menu(api)
631
+ elif action == "settings":
632
+ result = show_settings_menu(api)
633
+ if result == "switch_endpoint":
634
+ return "switch_endpoint"
635
+ elif action == "exit":
636
+ print("Exiting...")
637
+ add_log("Application exited by user.")
638
+ return "exit"
639
+ except KeyboardInterrupt:
640
+ print("\nExiting...")
641
+ add_log("Main menu exited by user (KeyboardInterrupt).")
642
+ return "exit"
643
+
644
+ def main():
645
+ """Main function to run the TUI application."""
646
+ add_log("Application started.")
647
+ while True:
648
+ profiles = load_profiles()
649
+
650
+ profile_choices = [
651
+ Choice(name=f"{p['name']} ({p['url']})", value=p) for p in profiles
652
+ ]
653
+ profile_choices.extend([
654
+ Separator(),
655
+ Choice(name="Add a new connection", value="new"),
656
+ Choice(name="Exit", value="exit")
657
+ ])
658
+
659
+ try:
660
+ selected_profile = inquirer.select(
661
+ message="Select a Clash connection profile:",
662
+ choices=profile_choices,
663
+ default=None,
664
+ ).execute()
665
+ except KeyboardInterrupt:
666
+ print("\nOperation cancelled by user. Exiting.")
667
+ add_log("Profile selection cancelled by user (KeyboardInterrupt).")
668
+ break
669
+
670
+ if selected_profile == "exit" or selected_profile is None:
671
+ add_log("Profile selection exited by user.")
672
+ break
673
+
674
+ api = None
675
+ if selected_profile == "new":
676
+ try:
677
+ url = inquirer.text(
678
+ message="Enter Clash controller URL (e.g., http://127.0.0.1:9090):",
679
+ validate=EmptyInputValidator()
680
+ ).execute()
681
+ secret = inquirer.text(message="Enter API secret (optional):").execute()
682
+ profile_name = inquirer.text(
683
+ message="Enter a name for this profile:",
684
+ default=url,
685
+ validate=EmptyInputValidator()
686
+ ).execute()
687
+ working_directory = inquirer.text(
688
+ message="Enter Clash working directory (e.g., ~/.config/clash):",
689
+ default=os.path.expanduser("~/.config/clash"),
690
+ validate=EmptyInputValidator()
691
+ ).execute()
692
+ except KeyboardInterrupt:
693
+ print("\nOperation cancelled by user. Exiting.")
694
+ add_log("New profile creation cancelled by user (KeyboardInterrupt).")
695
+ break
696
+
697
+ new_profile = {"name": profile_name, "url": url, "secret": secret, "working_directory": working_directory}
698
+ profiles.append(new_profile)
699
+ save_profiles(profiles)
700
+ add_log(f"New profile '{profile_name}' added.")
701
+
702
+ api = ClashAPI(base_url=url, secret=secret, working_directory=working_directory)
703
+ elif selected_profile:
704
+ # Find the actual profile object in the profiles list
705
+ current_profile_obj = None
706
+ for p in profiles:
707
+ if p['name'] == selected_profile['name'] and p['url'] == selected_profile['url']:
708
+ current_profile_obj = p
709
+ break
710
+
711
+ if current_profile_obj:
712
+ # Check for and prompt for working_directory if missing (for backward compatibility)
713
+ if 'working_directory' not in current_profile_obj or not current_profile_obj['working_directory']:
714
+ print(f"\nProfile '{current_profile_obj['name']}' is missing a working directory.")
715
+ try:
716
+ working_directory = inquirer.text(
717
+ message="Enter Clash working directory for this profile (e.g., ~/.config/clash):",
718
+ default=os.path.expanduser("~/.config/clash"),
719
+ validate=EmptyInputValidator()
720
+ ).execute()
721
+ current_profile_obj['working_directory'] = working_directory
722
+ save_profiles(profiles) # Now this should save the updated list
723
+ add_log(f"Updated profile '{current_profile_obj['name']}' with working directory '{working_directory}'.")
724
+ except KeyboardInterrupt:
725
+ print("\nOperation cancelled by user. Returning to profile selection.")
726
+ add_log("Working directory prompt cancelled by user (KeyboardInterrupt).")
727
+ continue # Return to profile selection instead of breaking
728
+
729
+ api = ClashAPI(base_url=current_profile_obj['url'], secret=current_profile_obj.get('secret'), working_directory=current_profile_obj['working_directory'])
730
+ add_log(f"Selected profile '{current_profile_obj['name']}'.")
731
+ else:
732
+ # This case should ideally not happen if selected_profile is always from profiles
733
+ print("Error: Selected profile not found in the loaded profiles list.")
734
+ add_log("Error: Selected profile not found in the loaded profiles list.")
735
+ continue
736
+
737
+ if api:
738
+ print("Connecting...")
739
+ add_log(f"Attempting to connect to Clash at {api.base_url}...")
740
+ version_info, error = api.get_version()
741
+ if version_info:
742
+ add_log(f"Successfully connected to Clash (version: {version_info.get('version', 'unknown')}).")
743
+ result = show_main_menu(api)
744
+ if result == "switch_endpoint":
745
+ print("\nReturning to endpoint selection...")
746
+ add_log("Returning to endpoint selection.")
747
+ continue
748
+ else:
749
+ break
750
+ else:
751
+ print(f"\nConnection failed. Please check your URL, secret, and make sure Clash is running. Error: {error}")
752
+ add_log(f"Connection failed to {api.base_url}. Error: {error}")
753
+ try:
754
+ go_back = inquirer.confirm(message="Go back to endpoint selection?", default=True).execute()
755
+ if go_back:
756
+ add_log("User chose to go back to endpoint selection.")
757
+ continue
758
+ else:
759
+ add_log("User chose to exit after connection failure.")
760
+ break
761
+ except KeyboardInterrupt:
762
+ print("\nExiting.")
763
+ add_log("User exited during connection failure prompt (KeyboardInterrupt).")
764
+ break
765
+
766
+ if __name__ == '__main__':
767
+ try:
768
+ main()
769
+ except Exception as e:
770
+ print(f"\nAn unexpected error occurred: {e}", file=sys.stderr)
771
+ add_log(f"An unexpected error occurred: {e}")
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: clash_controller
3
+ Version: 0.0.1
4
+ Summary: A controller for Clash
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: Bug Tracker, https://github.com/Moha-Master/Clash-Controller/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests
14
+ Requires-Dist: requests_unixsocket
15
+ Requires-Dist: InquirerPy
16
+
17
+ # Clash Controller
18
+
19
+ 一个使用 `InquirerPy` 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器。它可以让您方便地通过命令行管理和监控一个或多个 `clash` 实例。
20
+
21
+ ## 功能特性
22
+
23
+ - **交互式 TUI 界面**: 友好的菜单驱动操作,无需记忆复杂命令。
24
+ - **多端点管理**:
25
+ - 自动保存连接过的 Clash 端点(地址和密钥)。
26
+ - 启动时可从已保存列表中快速选择。
27
+ - 支持添加新的端点。
28
+ - 支持 HTTP 和 Unix Domain Socket 连接。
29
+ - **实时监控面板**:
30
+ - **概览 (Overview)**: 实时显示上/下行流量、内存使用和内核版本。
31
+ - **连接 (Connections)**: 实时展示当前的活动连接列表、总连接数和累计流量。
32
+ - **强大的设置菜单**:
33
+ - **模式切换**: 循环切换 `规则` / `全局` / `直连` 模式,并开关 `TUN` 模式。
34
+ - **重载与重启**: 独立地重载配置文件、GEO 数据库,或重启 Clash 核心。
35
+ - **一键升级**: 在线升级内核、UI 面板和 GEO 数据库。
36
+ - **查看完整配置**: 显示当前 Clash 的全部运行配置。
37
+
38
+ ## 安装
39
+
40
+ ```bash
41
+ pip install clash-controller
42
+ ```
43
+
44
+ ## 使用方法
45
+
46
+ 安装后,可以通过以下命令启动程序:
47
+
48
+ ```bash
49
+ clashctl
50
+ ```
51
+
52
+ 程序启动后,会提示您选择一个已保存的 Clash 端点或添加一个新的端点。
53
+
54
+ ## 开发者安装
55
+
56
+ 如果你想要从源代码运行或者参与开发:
57
+
58
+ ```bash
59
+ git clone https://github.com/Moha-Master/clash-controller.git
60
+ cd clash-controller
61
+
62
+ # 创建虚拟环境 (推荐)
63
+ python -m venv venv
64
+ source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
65
+
66
+ # 安装依赖
67
+ pip install -r requirements.txt
68
+
69
+ # 运行程序
70
+ python -m clash_controller
71
+ ```
72
+
73
+ ## 要求
74
+
75
+ - Python 3.8+
76
+ - 运行中的 Clash 实例,已开启外部控制 API
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ clash_controller/__init__.py
4
+ clash_controller/__main__.py
5
+ clash_controller/api.py
6
+ clash_controller/cli.py
7
+ clash_controller.egg-info/PKG-INFO
8
+ clash_controller.egg-info/SOURCES.txt
9
+ clash_controller.egg-info/dependency_links.txt
10
+ clash_controller.egg-info/entry_points.txt
11
+ clash_controller.egg-info/requires.txt
12
+ clash_controller.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clashctl = clash_controller.cli:main
@@ -0,0 +1,3 @@
1
+ requests
2
+ requests_unixsocket
3
+ InquirerPy
@@ -0,0 +1 @@
1
+ clash_controller
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clash_controller"
7
+ version = "0.0.1"
8
+ description = "A controller for Clash"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Moha-Master", email = "hongkongreporter@outlook.com" },
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "requests",
21
+ "requests_unixsocket",
22
+ "InquirerPy"
23
+ ]
24
+
25
+ # 命令行入口
26
+ [project.scripts]
27
+ clashctl = "clash_controller.cli:main"
28
+
29
+ # (可选) 项目链接
30
+ [project.urls]
31
+ "Homepage" = "https://github.com/Moha-Master/Clash-Controller"
32
+ "Bug Tracker" = "https://github.com/Moha-Master/Clash-Controller/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+