clash-controller 0.0.1__tar.gz → 0.0.2__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.0.1 → clash_controller-0.0.2}/PKG-INFO +3 -2
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller/api.py +141 -10
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller/cli.py +211 -168
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/PKG-INFO +3 -2
- {clash_controller-0.0.1 → clash_controller-0.0.2}/pyproject.toml +4 -5
- {clash_controller-0.0.1 → clash_controller-0.0.2}/README.md +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller/__init__.py +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller/__main__.py +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/SOURCES.txt +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.0.1 → clash_controller-0.0.2}/setup.cfg +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clash_controller
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: A controller for Clash
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Moha-Master/Clash-Controller
|
|
8
|
-
Project-URL:
|
|
8
|
+
Project-URL: Repository, https://github.com/Moha-Master/Clash-Controller
|
|
9
|
+
Project-URL: Issues, https://github.com/Moha-Master/Clash-Controller/issues
|
|
9
10
|
Classifier: Programming Language :: Python :: 3
|
|
10
11
|
Classifier: Operating System :: OS Independent
|
|
11
12
|
Requires-Python: >=3.8
|
|
@@ -2,7 +2,11 @@ import requests
|
|
|
2
2
|
from urllib.parse import quote
|
|
3
3
|
|
|
4
4
|
class ClashAPI:
|
|
5
|
-
|
|
5
|
+
# Special sentinel returned when the request was sent but the connection
|
|
6
|
+
# was interrupted (server closed connection / connection reset) and the
|
|
7
|
+
# client cannot determine final outcome. Caller should prompt user to verify.
|
|
8
|
+
SENT_BUT_DISCONNECTED = "SENT_BUT_DISCONNECTED"
|
|
9
|
+
def __init__(self, base_url, secret=None, timeout=10, working_directory=None, debug=False):
|
|
6
10
|
"""
|
|
7
11
|
Initializes the Clash API client.
|
|
8
12
|
|
|
@@ -16,6 +20,8 @@ class ClashAPI:
|
|
|
16
20
|
self.timeout = timeout
|
|
17
21
|
self.headers = {}
|
|
18
22
|
self.working_directory = working_directory
|
|
23
|
+
# When debug is True, the client will print raw request/response info
|
|
24
|
+
self.debug = bool(debug)
|
|
19
25
|
if secret:
|
|
20
26
|
self.headers['Authorization'] = f'Bearer {secret}'
|
|
21
27
|
|
|
@@ -27,26 +33,151 @@ class ClashAPI:
|
|
|
27
33
|
else:
|
|
28
34
|
self.session = requests.Session()
|
|
29
35
|
|
|
36
|
+
def _is_local(self):
|
|
37
|
+
# Determine if the configured base_url points to a local service.
|
|
38
|
+
try:
|
|
39
|
+
if self.base_url.startswith('http+unix://'):
|
|
40
|
+
return True
|
|
41
|
+
lower = self.base_url.lower()
|
|
42
|
+
return '127.0.0.1' in lower or 'localhost' in lower or '::1' in lower
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
30
46
|
def _request(self, method, endpoint, params=None, json_data=None, stream=False):
|
|
31
|
-
"""Helper method to make requests to the API.
|
|
47
|
+
"""Helper method to make requests to the API.
|
|
48
|
+
|
|
49
|
+
Behavior changes:
|
|
50
|
+
- Retries once on ReadTimeout with a longer timeout.
|
|
51
|
+
- Treats PUT /configs ReadTimeout conservatively as success (server-side reload may have completed).
|
|
52
|
+
- For streaming requests (stream=True) returns the raw response object so callers can iterate.
|
|
53
|
+
- When debug=True, prints basic request/response information to stdout.
|
|
54
|
+
"""
|
|
32
55
|
url = f"{self.base_url}{endpoint}"
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
|
|
57
|
+
def do_request(timeout_val):
|
|
58
|
+
return self.session.request(
|
|
35
59
|
method,
|
|
36
60
|
url,
|
|
37
61
|
headers=self.headers,
|
|
38
62
|
params=params,
|
|
39
63
|
json=json_data,
|
|
40
|
-
timeout=
|
|
64
|
+
timeout=timeout_val,
|
|
41
65
|
stream=stream
|
|
42
66
|
)
|
|
43
|
-
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
|
44
67
|
|
|
45
|
-
|
|
68
|
+
try:
|
|
69
|
+
if self.debug:
|
|
70
|
+
try:
|
|
71
|
+
print(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={self.timeout}")
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
response = do_request(self.timeout)
|
|
76
|
+
|
|
77
|
+
# If caller requested streaming, return the raw response so the caller can iterate
|
|
78
|
+
if stream:
|
|
79
|
+
if self.debug:
|
|
80
|
+
print(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} (streaming)")
|
|
81
|
+
return response, None
|
|
82
|
+
|
|
83
|
+
# For non-stream responses: always return the API-provided body (prefer JSON),
|
|
84
|
+
# along with no error. We do not auto-raise on 4xx/5xx because the Clash API
|
|
85
|
+
# encodes operation result details in the response body.
|
|
46
86
|
if response.status_code == 204 or not response.content:
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
if self.debug:
|
|
88
|
+
print(f"[DEBUG] Response: status={response.status_code} (no content)")
|
|
89
|
+
return {"status": "success", "status_code": response.status_code}, None
|
|
90
|
+
|
|
91
|
+
# Try JSON first, fall back to text
|
|
92
|
+
try:
|
|
93
|
+
parsed = response.json()
|
|
94
|
+
except Exception:
|
|
95
|
+
parsed = response.text
|
|
96
|
+
|
|
97
|
+
if self.debug:
|
|
98
|
+
raw = (response.text or '')[:4096]
|
|
99
|
+
print(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
100
|
+
|
|
101
|
+
# If the parsed result is a dict, inject status_code for caller convenience
|
|
102
|
+
if isinstance(parsed, dict):
|
|
103
|
+
parsed.setdefault('status_code', response.status_code)
|
|
104
|
+
return parsed, None
|
|
105
|
+
return {"body": parsed, "status_code": response.status_code}, None
|
|
106
|
+
|
|
107
|
+
except requests.exceptions.ReadTimeout as e:
|
|
108
|
+
# Retry once with an increased timeout. If retry fails due to
|
|
109
|
+
# connection problems and the API is local, return the sentinel so
|
|
110
|
+
# caller can ask user to verify the outcome manually.
|
|
111
|
+
try:
|
|
112
|
+
longer_timeout = max(self.timeout * 3, 15)
|
|
113
|
+
if self.debug:
|
|
114
|
+
print(f"[DEBUG] ReadTimeout occurred, retrying with timeout={longer_timeout}")
|
|
115
|
+
response = do_request(longer_timeout)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
|
|
118
|
+
if stream:
|
|
119
|
+
if self.debug:
|
|
120
|
+
print(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} (streaming)")
|
|
121
|
+
return response, None
|
|
122
|
+
|
|
123
|
+
if response.status_code == 204 or not response.content:
|
|
124
|
+
if self.debug:
|
|
125
|
+
print(f"[DEBUG] Response after retry: status={response.status_code} (no content)")
|
|
126
|
+
return {"status": "success"}, None
|
|
127
|
+
|
|
128
|
+
if self.debug:
|
|
129
|
+
raw = response.text[:4096]
|
|
130
|
+
print(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
131
|
+
return response.json(), None
|
|
132
|
+
|
|
133
|
+
except requests.exceptions.RequestException as exc:
|
|
134
|
+
if self.debug:
|
|
135
|
+
print(f"[DEBUG] ReadTimeout retry failed: {exc}")
|
|
136
|
+
# If this is a local API, we conservatively cannot determine the final
|
|
137
|
+
# state (server may have applied the operation then closed the connection),
|
|
138
|
+
# so inform the caller to verify manually.
|
|
139
|
+
if self._is_local():
|
|
140
|
+
return None, self.SENT_BUT_DISCONNECTED
|
|
141
|
+
return None, str(e)
|
|
142
|
+
|
|
143
|
+
except (requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError) as e:
|
|
144
|
+
# Connection errors/remote close — if local, return sentinel so caller
|
|
145
|
+
# can prompt user to manually verify; otherwise return the error.
|
|
146
|
+
if self.debug:
|
|
147
|
+
print(f"[DEBUG] Connection error: {e}")
|
|
148
|
+
if self._is_local():
|
|
149
|
+
return None, self.SENT_BUT_DISCONNECTED
|
|
150
|
+
return None, str(e)
|
|
151
|
+
|
|
49
152
|
except requests.exceptions.RequestException as e:
|
|
153
|
+
# If requests has attached a response, treat the response body as the
|
|
154
|
+
# authoritative API result (even for 4xx/5xx); only when no response is
|
|
155
|
+
# available do we treat this as a transport-level failure.
|
|
156
|
+
resp = getattr(e, 'response', None)
|
|
157
|
+
if resp is not None:
|
|
158
|
+
try:
|
|
159
|
+
body = resp.json()
|
|
160
|
+
except Exception:
|
|
161
|
+
try:
|
|
162
|
+
body = resp.text
|
|
163
|
+
except Exception:
|
|
164
|
+
body = ''
|
|
165
|
+
|
|
166
|
+
if self.debug:
|
|
167
|
+
try:
|
|
168
|
+
print(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp.text or '')[:4096]}")
|
|
169
|
+
except Exception:
|
|
170
|
+
print(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
|
|
171
|
+
|
|
172
|
+
# Return the response body as the result, do not treat as exception
|
|
173
|
+
if isinstance(body, dict):
|
|
174
|
+
body.setdefault('status_code', resp.status_code)
|
|
175
|
+
return body, None
|
|
176
|
+
return {"body": body, "status_code": resp.status_code}, None
|
|
177
|
+
|
|
178
|
+
# No response available: transport-level error
|
|
179
|
+
if self.debug:
|
|
180
|
+
print(f"[DEBUG] RequestException without response (transport error): {e}")
|
|
50
181
|
return None, str(e)
|
|
51
182
|
|
|
52
183
|
# === Real-time Data ===
|
|
@@ -194,4 +325,4 @@ class ClashAPI:
|
|
|
194
325
|
# === DNS ===
|
|
195
326
|
def query_dns(self, name: str, query_type: str = 'A'):
|
|
196
327
|
"""Query DNS."""
|
|
197
|
-
return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
|
|
328
|
+
return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
|
|
@@ -24,6 +24,34 @@ COLOR_RESET = '\033[0m'
|
|
|
24
24
|
PROFILE_PATH = os.path.expanduser("~/.config/clash-controller/profiles.json")
|
|
25
25
|
CONFIG_PROVIDERS_PATH = os.path.expanduser("~/.config/clash-controller/config_providers.json")
|
|
26
26
|
TEMP_CONFIG_DIR = os.path.expanduser("~/.config/clash-controller/temp_configs")
|
|
27
|
+
CONFIG_BACKUP_BASE_DIR = os.path.expanduser("~/.config/clash-controller/config_backups")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_endpoint_key(api: ClashAPI) -> str:
|
|
31
|
+
base = (api.base_url or "unknown").replace("http+unix://", "unix-")
|
|
32
|
+
safe = []
|
|
33
|
+
for ch in base:
|
|
34
|
+
if ch.isalnum() or ch in ('-', '_', '.'):
|
|
35
|
+
safe.append(ch)
|
|
36
|
+
else:
|
|
37
|
+
safe.append('_')
|
|
38
|
+
return ''.join(safe).strip('_') or 'unknown'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_backup_paths(api: ClashAPI):
|
|
42
|
+
endpoint_key = _safe_endpoint_key(api)
|
|
43
|
+
endpoint_dir = os.path.join(CONFIG_BACKUP_BASE_DIR, endpoint_key)
|
|
44
|
+
latest_payload_path = os.path.join(endpoint_dir, "config.latest.yaml")
|
|
45
|
+
backup_payload_path = os.path.join(endpoint_dir, "config.yaml.bak")
|
|
46
|
+
latest_runtime_json = os.path.join(endpoint_dir, "runtime.latest.json")
|
|
47
|
+
backup_runtime_json = os.path.join(endpoint_dir, "runtime.bak.json")
|
|
48
|
+
return {
|
|
49
|
+
"endpoint_dir": endpoint_dir,
|
|
50
|
+
"latest_payload_path": latest_payload_path,
|
|
51
|
+
"backup_payload_path": backup_payload_path,
|
|
52
|
+
"latest_runtime_json": latest_runtime_json,
|
|
53
|
+
"backup_runtime_json": backup_runtime_json,
|
|
54
|
+
}
|
|
27
55
|
|
|
28
56
|
def get_remote_last_modified(url: str) -> datetime or None:
|
|
29
57
|
"""Fetches the Last-Modified header from a remote URL."""
|
|
@@ -79,6 +107,114 @@ def show_logs_screen():
|
|
|
79
107
|
print("-" * 80)
|
|
80
108
|
input("Press Enter to return to the settings menu...")
|
|
81
109
|
|
|
110
|
+
|
|
111
|
+
def handle_api_result(api: ClashAPI, action_desc: str, result, error):
|
|
112
|
+
"""Unified handling for API results.
|
|
113
|
+
|
|
114
|
+
- If error is None -> treat as success and log it.
|
|
115
|
+
- If error equals ClashAPI.SENT_BUT_DISCONNECTED -> notify user the request was sent but
|
|
116
|
+
the connection was interrupted; advise manual verification.
|
|
117
|
+
- Otherwise -> treat as failure and show error.
|
|
118
|
+
"""
|
|
119
|
+
if error is None:
|
|
120
|
+
# Prefer concise, user-friendly messages derived from the API result.
|
|
121
|
+
def _clean_msg(raw: str) -> str:
|
|
122
|
+
if not raw:
|
|
123
|
+
return ''
|
|
124
|
+
s = raw.strip()
|
|
125
|
+
# remove common prefixes
|
|
126
|
+
for prefix in ('update error:', 'error:', 'message:', 'update:'):
|
|
127
|
+
if s.lower().startswith(prefix):
|
|
128
|
+
s = s[len(prefix):].strip()
|
|
129
|
+
break
|
|
130
|
+
# Capitalize first letter
|
|
131
|
+
if s:
|
|
132
|
+
s = s[0].upper() + s[1:]
|
|
133
|
+
return s
|
|
134
|
+
|
|
135
|
+
short_msg = ''
|
|
136
|
+
status_code = None
|
|
137
|
+
if isinstance(result, dict):
|
|
138
|
+
status_code = result.get('status_code') or result.get('status') if isinstance(result.get('status'), int) else None
|
|
139
|
+
status_val = str(result.get('status', '')).lower()
|
|
140
|
+
# Prefer explicit message/info keys
|
|
141
|
+
if 'message' in result:
|
|
142
|
+
short_msg = _clean_msg(str(result['message']))
|
|
143
|
+
elif 'info' in result:
|
|
144
|
+
short_msg = _clean_msg(str(result['info']))
|
|
145
|
+
elif 'body' in result and isinstance(result['body'], str):
|
|
146
|
+
short_msg = _clean_msg(result['body'])
|
|
147
|
+
|
|
148
|
+
# Determine success heuristics: explicit status value or HTTP-like code
|
|
149
|
+
success = False
|
|
150
|
+
if status_val in ('success', 'ok', 'done', 'true'):
|
|
151
|
+
success = True
|
|
152
|
+
if isinstance(status_code, int) and status_code in (200, 201, 202, 204):
|
|
153
|
+
success = True
|
|
154
|
+
|
|
155
|
+
if success:
|
|
156
|
+
print(f"Successfully {action_desc}.")
|
|
157
|
+
if short_msg:
|
|
158
|
+
print(f" Note: {short_msg}")
|
|
159
|
+
add_log(f"Successfully {action_desc}. {short_msg}")
|
|
160
|
+
else:
|
|
161
|
+
# Not an explicit success; surface the message as info/warning
|
|
162
|
+
if short_msg:
|
|
163
|
+
print(f"{action_desc}: {short_msg}")
|
|
164
|
+
add_log(f"{action_desc}: {short_msg}")
|
|
165
|
+
else:
|
|
166
|
+
print(f"{action_desc} completed. (see details)")
|
|
167
|
+
add_log(f"{action_desc} completed with no short message.")
|
|
168
|
+
|
|
169
|
+
# In debug mode, also print the full result JSON for inspection
|
|
170
|
+
if api and getattr(api, 'debug', False):
|
|
171
|
+
try:
|
|
172
|
+
import json as _json
|
|
173
|
+
pretty = _json.dumps(result, indent=2, ensure_ascii=False)
|
|
174
|
+
print(f"[DEBUG] Full API result:\n{pretty}")
|
|
175
|
+
except Exception:
|
|
176
|
+
print(f"[DEBUG] Full API result: {result}")
|
|
177
|
+
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
# Non-dict results: just print a concise line and debug details if available
|
|
181
|
+
try:
|
|
182
|
+
s = str(result)
|
|
183
|
+
if s:
|
|
184
|
+
print(f"{action_desc} completed: {s}")
|
|
185
|
+
add_log(f"{action_desc} completed: {s}")
|
|
186
|
+
else:
|
|
187
|
+
print(f"Successfully {action_desc}.")
|
|
188
|
+
add_log(f"Successfully {action_desc}.")
|
|
189
|
+
except Exception:
|
|
190
|
+
print(f"Successfully {action_desc}.")
|
|
191
|
+
add_log(f"Successfully {action_desc}.")
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
# Handle sentinel for sent-but-disconnected
|
|
195
|
+
try:
|
|
196
|
+
sentinel = api.SENT_BUT_DISCONNECTED
|
|
197
|
+
except Exception:
|
|
198
|
+
sentinel = None
|
|
199
|
+
|
|
200
|
+
if error == sentinel:
|
|
201
|
+
print(f"Request '{action_desc}' was sent but the connection was interrupted before a final response could be read.")
|
|
202
|
+
print("The operation may have been applied. Please check Clash logs/status manually to confirm.")
|
|
203
|
+
add_log(f"Request '{action_desc}' may have been applied but connection closed before response.")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
# Generic failure
|
|
207
|
+
# If the API included an info body, show it even when error happened
|
|
208
|
+
if isinstance(result, dict) and 'info' in result and result.get('info'):
|
|
209
|
+
info = str(result.get('info'))[:2000]
|
|
210
|
+
print(f"{action_desc} completed: {info}")
|
|
211
|
+
add_log(f"{action_desc} completed with info: {info}")
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
print(f"Failed to {action_desc}: {error}")
|
|
215
|
+
add_log(f"Failed to {action_desc}: {error}")
|
|
216
|
+
return False
|
|
217
|
+
|
|
82
218
|
def load_profiles():
|
|
83
219
|
"""Loads connection profiles from the config file."""
|
|
84
220
|
if not os.path.exists(PROFILE_PATH):
|
|
@@ -295,12 +431,6 @@ def show_overview_page(api: ClashAPI):
|
|
|
295
431
|
|
|
296
432
|
def show_config_menu(api: ClashAPI):
|
|
297
433
|
"""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
434
|
while True:
|
|
305
435
|
|
|
306
436
|
try:
|
|
@@ -312,7 +442,7 @@ def show_config_menu(api: ClashAPI):
|
|
|
312
442
|
provider_choices.extend([
|
|
313
443
|
Separator(),
|
|
314
444
|
Choice(name="Add new config provider", value="new"),
|
|
315
|
-
Choice(name="Reload Config File
|
|
445
|
+
Choice(name="Reload Config File", value="reload_local"),
|
|
316
446
|
Separator(),
|
|
317
447
|
Choice(name="Back to Main Menu", value="back"),
|
|
318
448
|
])
|
|
@@ -345,125 +475,74 @@ def show_config_menu(api: ClashAPI):
|
|
|
345
475
|
print(f"New config provider '{provider_name}' added.")
|
|
346
476
|
|
|
347
477
|
elif action == "reload_local":
|
|
348
|
-
print("\nReloading config file
|
|
349
|
-
|
|
350
|
-
|
|
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}")
|
|
478
|
+
print("\nReloading config file...")
|
|
479
|
+
result, error = api.reload_configs()
|
|
480
|
+
handle_api_result(api, "Reload config", result, error)
|
|
356
481
|
|
|
357
482
|
elif action == "back":
|
|
358
483
|
return None
|
|
359
484
|
elif action: # A saved config provider was selected
|
|
360
485
|
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
486
|
|
|
369
|
-
print(f"Fetching config from {provider_url}
|
|
370
|
-
add_log(f"Fetching config from {provider_url}
|
|
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.")
|
|
487
|
+
print(f"Fetching config from {provider_url}...")
|
|
488
|
+
add_log(f"Fetching config from {provider_url}...")
|
|
405
489
|
|
|
406
|
-
if not proceed_download:
|
|
407
|
-
input("Press Enter to continue...")
|
|
408
|
-
continue
|
|
409
|
-
|
|
410
490
|
try:
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
print(
|
|
429
|
-
add_log(
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
add_log(f"Failed to reload config file via Clash API: {error}")
|
|
491
|
+
response = requests.get(provider_url, timeout=30)
|
|
492
|
+
response.raise_for_status()
|
|
493
|
+
remote_payload = response.text
|
|
494
|
+
|
|
495
|
+
backups = _get_backup_paths(api)
|
|
496
|
+
os.makedirs(backups['endpoint_dir'], exist_ok=True)
|
|
497
|
+
|
|
498
|
+
# Compare with last successfully applied payload cache
|
|
499
|
+
previous_payload = None
|
|
500
|
+
if os.path.exists(backups['latest_payload_path']):
|
|
501
|
+
try:
|
|
502
|
+
with open(backups['latest_payload_path'], 'r', encoding='utf-8') as f:
|
|
503
|
+
previous_payload = f.read()
|
|
504
|
+
except IOError:
|
|
505
|
+
previous_payload = None
|
|
506
|
+
|
|
507
|
+
if previous_payload is not None and previous_payload == remote_payload:
|
|
508
|
+
print("Remote config content is identical to last applied payload. Skipping update.")
|
|
509
|
+
add_log("Remote config content is identical to last applied payload. Skipped update.")
|
|
510
|
+
input("Press Enter to continue...")
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
# Backup previous cached payload/runtime snapshots before applying
|
|
514
|
+
if os.path.exists(backups['latest_payload_path']):
|
|
515
|
+
os.replace(backups['latest_payload_path'], backups['backup_payload_path'])
|
|
516
|
+
runtime_config, runtime_error = api.get_configs()
|
|
517
|
+
if runtime_error:
|
|
518
|
+
add_log(f"Warning: failed to fetch runtime config before update: {runtime_error}")
|
|
519
|
+
elif runtime_config is not None:
|
|
520
|
+
if os.path.exists(backups['latest_runtime_json']):
|
|
521
|
+
os.replace(backups['latest_runtime_json'], backups['backup_runtime_json'])
|
|
522
|
+
with open(backups['latest_runtime_json'], 'w', encoding='utf-8') as f:
|
|
523
|
+
json.dump(runtime_config, f, indent=2, ensure_ascii=False)
|
|
524
|
+
|
|
525
|
+
print("Applying fetched config via Clash API (PUT /configs?force=true, payload)...")
|
|
526
|
+
add_log("Applying fetched config via Clash API with payload.")
|
|
527
|
+
result, error = api.reload_configs(payload=remote_payload)
|
|
528
|
+
ok = handle_api_result(api, "Apply remote config payload", result, error)
|
|
529
|
+
|
|
530
|
+
# Save payload cache only when API call was accepted/handled
|
|
531
|
+
if ok:
|
|
532
|
+
with open(backups['latest_payload_path'], 'w', encoding='utf-8') as f:
|
|
533
|
+
f.write(remote_payload)
|
|
534
|
+
print(f"Updated local payload backup at: {backups['latest_payload_path']}")
|
|
535
|
+
add_log(f"Updated local payload backup at {backups['latest_payload_path']}")
|
|
457
536
|
|
|
458
537
|
except requests.exceptions.RequestException as e:
|
|
459
538
|
print(f"Error fetching config: {e}")
|
|
460
539
|
add_log(f"Error fetching config from {provider_url}: {e}")
|
|
461
540
|
except IOError as e:
|
|
462
|
-
print(f"Error
|
|
463
|
-
add_log(f"Error
|
|
541
|
+
print(f"Error writing local backup files: {e}")
|
|
542
|
+
add_log(f"Error writing local backup files: {e}")
|
|
464
543
|
except Exception as e:
|
|
465
|
-
print(f"An unexpected error occurred during config
|
|
466
|
-
add_log(f"Unexpected error during config
|
|
544
|
+
print(f"An unexpected error occurred during remote config apply: {e}")
|
|
545
|
+
add_log(f"Unexpected error during remote config apply: {e}")
|
|
467
546
|
input("Press Enter to continue...")
|
|
468
547
|
|
|
469
548
|
except KeyboardInterrupt:
|
|
@@ -510,13 +589,8 @@ def show_settings_menu(api: ClashAPI):
|
|
|
510
589
|
|
|
511
590
|
if action == "toggle_tun":
|
|
512
591
|
new_state = not tun_enabled
|
|
513
|
-
|
|
514
|
-
if
|
|
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}")
|
|
592
|
+
result, error = api.toggle_tun(new_state)
|
|
593
|
+
handle_api_result(api, f"Toggle TUN {'enable' if new_state else 'disable'}", result, error)
|
|
520
594
|
elif action == "switch_mode":
|
|
521
595
|
modes = ['rule', 'global', 'direct']
|
|
522
596
|
current_mode_lower = current_configs.get('mode', 'rule')
|
|
@@ -526,58 +600,28 @@ def show_settings_menu(api: ClashAPI):
|
|
|
526
600
|
next_mode = modes[next_index]
|
|
527
601
|
except ValueError:
|
|
528
602
|
next_mode = 'rule' # Default if current mode is not in list
|
|
529
|
-
|
|
530
|
-
|
|
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}")
|
|
603
|
+
result, error = api.set_mode(next_mode)
|
|
604
|
+
handle_api_result(api, f"Switch mode to {next_mode}", result, error)
|
|
536
605
|
elif action == "reload_geo":
|
|
537
606
|
print("\nRequesting GEO databases reload...")
|
|
538
|
-
|
|
539
|
-
|
|
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}")
|
|
607
|
+
result, error = api.reload_geo_databases()
|
|
608
|
+
handle_api_result(api, "Reload GEO databases", result, error)
|
|
545
609
|
elif action == "restart":
|
|
546
610
|
print("\nRestarting Clash Core...")
|
|
547
|
-
|
|
548
|
-
|
|
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}")
|
|
611
|
+
result, error = api.restart()
|
|
612
|
+
handle_api_result(api, "Restart Clash Core", result, error)
|
|
554
613
|
elif action == "upgrade_kernel":
|
|
555
614
|
print("\nRequesting Kernel upgrade...")
|
|
556
|
-
|
|
557
|
-
|
|
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}")
|
|
615
|
+
result, error = api.upgrade_kernel()
|
|
616
|
+
handle_api_result(api, "Upgrade Kernel", result, error)
|
|
563
617
|
elif action == "upgrade_ui":
|
|
564
618
|
print("\nRequesting UI upgrade...")
|
|
565
|
-
|
|
566
|
-
|
|
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}")
|
|
619
|
+
result, error = api.upgrade_ui()
|
|
620
|
+
handle_api_result(api, "Upgrade UI", result, error)
|
|
572
621
|
elif action == "upgrade_geo":
|
|
573
622
|
print("\nRequesting GEO databases upgrade...")
|
|
574
|
-
|
|
575
|
-
|
|
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}")
|
|
623
|
+
result, error = api.upgrade_geo_databases()
|
|
624
|
+
handle_api_result(api, "Upgrade GEO databases", result, error)
|
|
581
625
|
elif action == "switch_endpoint":
|
|
582
626
|
return "switch_endpoint"
|
|
583
627
|
elif action == "view_logs": # Handle new logs option
|
|
@@ -606,10 +650,7 @@ def show_main_menu(api: ClashAPI):
|
|
|
606
650
|
Choice(name="Connections", value="connections"),
|
|
607
651
|
]
|
|
608
652
|
|
|
609
|
-
|
|
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))
|
|
653
|
+
choices_list.append(Choice(name="Configuration", value="configuration"))
|
|
613
654
|
|
|
614
655
|
choices_list.extend([
|
|
615
656
|
Choice(name="Settings", value="settings"),
|
|
@@ -644,6 +685,8 @@ def show_main_menu(api: ClashAPI):
|
|
|
644
685
|
def main():
|
|
645
686
|
"""Main function to run the TUI application."""
|
|
646
687
|
add_log("Application started.")
|
|
688
|
+
# Support a global --debug flag to enable request/response debugging in ClashAPI
|
|
689
|
+
debug_mode = ('--debug' in sys.argv)
|
|
647
690
|
while True:
|
|
648
691
|
profiles = load_profiles()
|
|
649
692
|
|
|
@@ -699,7 +742,7 @@ def main():
|
|
|
699
742
|
save_profiles(profiles)
|
|
700
743
|
add_log(f"New profile '{profile_name}' added.")
|
|
701
744
|
|
|
702
|
-
api = ClashAPI(base_url=url, secret=secret, working_directory=working_directory)
|
|
745
|
+
api = ClashAPI(base_url=url, secret=secret, working_directory=working_directory, debug=debug_mode)
|
|
703
746
|
elif selected_profile:
|
|
704
747
|
# Find the actual profile object in the profiles list
|
|
705
748
|
current_profile_obj = None
|
|
@@ -726,7 +769,7 @@ def main():
|
|
|
726
769
|
add_log("Working directory prompt cancelled by user (KeyboardInterrupt).")
|
|
727
770
|
continue # Return to profile selection instead of breaking
|
|
728
771
|
|
|
729
|
-
api = ClashAPI(base_url=current_profile_obj['url'], secret=current_profile_obj.get('secret'), working_directory=current_profile_obj['working_directory'])
|
|
772
|
+
api = ClashAPI(base_url=current_profile_obj['url'], secret=current_profile_obj.get('secret'), working_directory=current_profile_obj['working_directory'], debug=debug_mode)
|
|
730
773
|
add_log(f"Selected profile '{current_profile_obj['name']}'.")
|
|
731
774
|
else:
|
|
732
775
|
# This case should ideally not happen if selected_profile is always from profiles
|
|
@@ -768,4 +811,4 @@ if __name__ == '__main__':
|
|
|
768
811
|
main()
|
|
769
812
|
except Exception as e:
|
|
770
813
|
print(f"\nAn unexpected error occurred: {e}", file=sys.stderr)
|
|
771
|
-
add_log(f"An unexpected error occurred: {e}")
|
|
814
|
+
add_log(f"An unexpected error occurred: {e}")
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clash_controller
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: A controller for Clash
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Moha-Master/Clash-Controller
|
|
8
|
-
Project-URL:
|
|
8
|
+
Project-URL: Repository, https://github.com/Moha-Master/Clash-Controller
|
|
9
|
+
Project-URL: Issues, https://github.com/Moha-Master/Clash-Controller/issues
|
|
9
10
|
Classifier: Programming Language :: Python :: 3
|
|
10
11
|
Classifier: Operating System :: OS Independent
|
|
11
12
|
Requires-Python: >=3.8
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clash_controller"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.2"
|
|
8
8
|
description = "A controller for Clash"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -22,11 +22,10 @@ dependencies = [
|
|
|
22
22
|
"InquirerPy"
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
-
# 命令行入口
|
|
26
25
|
[project.scripts]
|
|
27
26
|
clashctl = "clash_controller.cli:main"
|
|
28
27
|
|
|
29
|
-
# (可选) 项目链接
|
|
30
28
|
[project.urls]
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
Homepage = "https://github.com/Moha-Master/Clash-Controller"
|
|
30
|
+
Repository = "https://github.com/Moha-Master/Clash-Controller"
|
|
31
|
+
Issues = "https://github.com/Moha-Master/Clash-Controller/issues"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{clash_controller-0.0.1 → clash_controller-0.0.2}/clash_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|