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.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clash_controller
3
- Version: 0.0.1
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: Bug Tracker, https://github.com/Moha-Master/Clash-Controller/issues
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
- def __init__(self, base_url, secret=None, timeout=5, working_directory=None):
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
- try:
34
- response = self.session.request(
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=self.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
- # For successful responses, return JSON if content exists, otherwise return a success indicator
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
- return {"status": "success"}, None
48
- return response.json(), None
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 (Local)", value="reload_local"),
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 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}")
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} 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.")
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
- 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}")
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 saving config file: {e}")
463
- add_log(f"Error saving config file: {e}")
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 download/save: {e}")
466
- add_log(f"Unexpected error during config download/save: {e}")
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
- _, 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}")
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
- _, 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}")
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
- _, 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}")
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
- _, 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}")
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
- _, 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}")
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
- _, 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}")
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
- _, 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}")
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
- 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))
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.1
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: Bug Tracker, https://github.com/Moha-Master/Clash-Controller/issues
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.1"
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
- "Homepage" = "https://github.com/Moha-Master/Clash-Controller"
32
- "Bug Tracker" = "https://github.com/Moha-Master/Clash-Controller/issues"
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"