screenforge 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. utils/utils_xml.py +218 -0
@@ -0,0 +1,273 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import time
8
+
9
+ import config.config as config
10
+ from common.logs import log
11
+
12
+ from .base_adapter import BasePlatformAdapter
13
+
14
+ _SESSION_FILE = os.path.abspath(os.path.join("report", "android_session.json"))
15
+
16
+
17
+ def _read_session() -> dict | None:
18
+ if not os.path.exists(_SESSION_FILE):
19
+ return None
20
+ try:
21
+ with open(_SESSION_FILE, "r") as f:
22
+ return json.load(f)
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def _write_session(serial: str) -> None:
28
+ os.makedirs(os.path.dirname(_SESSION_FILE), exist_ok=True)
29
+ with open(_SESSION_FILE, "w") as f:
30
+ json.dump({"serial": serial, "ts": time.time()}, f)
31
+
32
+
33
+ def _clear_session() -> None:
34
+ if os.path.exists(_SESSION_FILE):
35
+ os.remove(_SESSION_FILE)
36
+
37
+
38
+ def _is_device_online(serial: str) -> bool:
39
+ try:
40
+ result = subprocess.run(
41
+ ["adb", "devices"], capture_output=True, text=True, timeout=5,
42
+ )
43
+ for line in result.stdout.strip().splitlines()[1:]:
44
+ parts = line.split()
45
+ if len(parts) >= 2 and parts[1] == "device":
46
+ if not serial or parts[0] == serial:
47
+ return True
48
+ return False
49
+ except Exception:
50
+ return False
51
+
52
+
53
+ class AndroidU2Adapter(BasePlatformAdapter):
54
+
55
+ def __init__(self):
56
+ super().__init__()
57
+ self._serial = config.ANDROID_SERIAL
58
+ self._scrcpy_process = None
59
+
60
+ def setup(self):
61
+ log.info("⏳ [Setup] Initializing Android (u2) device...")
62
+
63
+ try:
64
+ import uiautomator2 as u2
65
+ except ImportError:
66
+ log.error(
67
+ "❌ [E050] uiautomator2 not installed. "
68
+ "Fix: pip install screenforge[android] or pip install uiautomator2"
69
+ )
70
+ raise RuntimeError(
71
+ "uiautomator2 not installed. Run: pip install screenforge[android]"
72
+ )
73
+
74
+ if self._try_reconnect(u2):
75
+ return
76
+
77
+ self._connect_fresh(u2)
78
+
79
+ def _try_reconnect(self, u2_module) -> bool:
80
+ session = _read_session()
81
+ if not session:
82
+ return False
83
+
84
+ serial = session.get("serial", "")
85
+ if not serial or not _is_device_online(serial):
86
+ log.info("⚠️ [System] Previous Android session device offline, connecting fresh")
87
+ _clear_session()
88
+ return False
89
+
90
+ try:
91
+ self.driver = u2_module.connect(serial)
92
+ self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
93
+ info = self.driver.info
94
+ log.info(
95
+ f"✅ [System] Reconnected to Android device "
96
+ f"({info.get('productName', 'unknown')}, serial: {serial})"
97
+ )
98
+ self._serial = serial
99
+ return True
100
+ except Exception as e:
101
+ log.info(f"⚠️ [System] Reconnect failed ({e}), connecting fresh")
102
+ _clear_session()
103
+ return False
104
+
105
+ def _connect_fresh(self, u2_module):
106
+ serial = self._serial
107
+
108
+ if serial and not _is_device_online(serial):
109
+ log.error(
110
+ f"❌ [E051] Android device '{serial}' not found or offline. "
111
+ "Fix: check 'adb devices' output and ensure the device is connected"
112
+ )
113
+ raise RuntimeError(
114
+ f"Android device '{serial}' not found. Run 'adb devices' to verify."
115
+ )
116
+
117
+ if not serial and not _is_device_online(""):
118
+ log.error(
119
+ "❌ [E052] No Android device connected. "
120
+ "Fix: connect a device via USB or start an emulator, then run 'adb devices'"
121
+ )
122
+ raise RuntimeError(
123
+ "No Android device connected. Run 'adb devices' to verify."
124
+ )
125
+
126
+ try:
127
+ connect_arg = serial if serial else None
128
+ self.driver = u2_module.connect(connect_arg)
129
+ self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
130
+ self._serial = self.driver.serial
131
+ _write_session(self._serial)
132
+
133
+ info = self.driver.info
134
+ log.info(
135
+ f"✅ [System] Connected to Android device "
136
+ f"({info.get('productName', 'unknown')}, "
137
+ f"SDK: {info.get('sdkInt', '?')}, serial: {self._serial})"
138
+ )
139
+ except Exception as e:
140
+ log.error(f"❌ [E053] Failed to connect to Android device: {e}")
141
+ raise RuntimeError(f"Android device connection failed: {e}")
142
+
143
+ def teardown(self):
144
+ log.info("⏳ [Teardown] Disconnecting Android device...")
145
+ if self._scrcpy_process:
146
+ self.stop_record_and_get_path("")
147
+ if self.driver:
148
+ try:
149
+ self.driver.service("uiautomator").stop()
150
+ except Exception:
151
+ pass
152
+ self.driver = None
153
+ log.info("✅ [System] Android device disconnected")
154
+
155
+ def start_record(self, video_name: str):
156
+ log.info("📹 [System] Starting scrcpy recording...")
157
+ try:
158
+ serial = self._serial or (self.driver.serial if self.driver else "")
159
+ if not serial:
160
+ log.warning("⚠️ [Warning] Cannot determine device serial for recording")
161
+ return
162
+
163
+ cmd = [
164
+ "scrcpy",
165
+ "-s", serial,
166
+ "--no-playback",
167
+ "--record", video_name,
168
+ "--video-bit-rate", "2M",
169
+ "--max-fps", "30",
170
+ ]
171
+
172
+ popen_kwargs = {
173
+ "stdout": subprocess.DEVNULL,
174
+ "stderr": subprocess.DEVNULL,
175
+ }
176
+ if sys.platform == "win32":
177
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
178
+ else:
179
+ popen_kwargs["preexec_fn"] = os.setsid
180
+
181
+ self._scrcpy_process = subprocess.Popen(cmd, **popen_kwargs)
182
+ time.sleep(1.0)
183
+
184
+ if self._scrcpy_process.poll() is not None:
185
+ log.error("❌ [Error] scrcpy crashed on startup")
186
+ self._scrcpy_process = None
187
+
188
+ except FileNotFoundError:
189
+ log.error(
190
+ "❌ [Error] scrcpy not found in PATH. "
191
+ "Fix: brew install scrcpy (macOS) or see https://github.com/Genymobile/scrcpy"
192
+ )
193
+ except Exception as e:
194
+ log.error(f"❌ [Error] Failed to start scrcpy: {e}")
195
+
196
+ def stop_record_and_get_path(self, video_name: str) -> str:
197
+ if not self._scrcpy_process:
198
+ return ""
199
+
200
+ log.info("⏳ [System] Stopping scrcpy recording...")
201
+ try:
202
+ if sys.platform == "win32":
203
+ self._scrcpy_process.send_signal(signal.CTRL_BREAK_EVENT)
204
+ else:
205
+ pgid = os.getpgid(self._scrcpy_process.pid)
206
+ os.killpg(pgid, signal.SIGINT)
207
+ self._scrcpy_process.wait(timeout=5.0)
208
+ except subprocess.TimeoutExpired:
209
+ log.warning("[Warning] scrcpy did not exit in time, force killing...")
210
+ try:
211
+ if sys.platform == "win32":
212
+ self._scrcpy_process.kill()
213
+ else:
214
+ pgid = os.getpgid(self._scrcpy_process.pid)
215
+ os.killpg(pgid, signal.SIGKILL)
216
+ except Exception:
217
+ self._scrcpy_process.kill()
218
+ self._scrcpy_process.wait()
219
+ except OSError:
220
+ try:
221
+ self._scrcpy_process.send_signal(signal.SIGINT)
222
+ self._scrcpy_process.wait(timeout=2)
223
+ except Exception:
224
+ self._scrcpy_process.kill()
225
+ self._scrcpy_process.wait()
226
+ except Exception as e:
227
+ log.error(f"❌ [Error] Failed to stop scrcpy: {e}")
228
+
229
+ self._scrcpy_process = None
230
+ return self._validate_video_file(video_name)
231
+
232
+ def take_screenshot(self, _retry: bool = True) -> bytes:
233
+ if not self.driver:
234
+ log.error("❌ [E054] Cannot take screenshot: no device connection")
235
+ return b""
236
+ try:
237
+ image = self.driver.screenshot()
238
+ img_bytes = io.BytesIO()
239
+ image.save(img_bytes, format='PNG')
240
+ return img_bytes.getvalue()
241
+ except Exception as e:
242
+ log.error(f"❌ [E055] Screenshot failed: {e}")
243
+ if _retry and self._attempt_reconnect():
244
+ return self.take_screenshot(_retry=False)
245
+ return b""
246
+
247
+ def _attempt_reconnect(self) -> bool:
248
+ log.info("⚠️ [System] Attempting Android device reconnect...")
249
+ try:
250
+ import uiautomator2 as u2
251
+ if _is_device_online(self._serial):
252
+ connect_arg = self._serial if self._serial else None
253
+ self.driver = u2.connect(connect_arg)
254
+ self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
255
+ log.info("✅ [System] Android device reconnected successfully")
256
+ return True
257
+ except Exception as e:
258
+ log.error(f"❌ [System] Reconnect failed: {e}")
259
+ return False
260
+
261
+ def _validate_video_file(self, video_name: str) -> str:
262
+ if not video_name:
263
+ return ""
264
+ if os.path.exists(video_name):
265
+ file_size = os.path.getsize(video_name)
266
+ if file_size < 1024:
267
+ log.warning(f"⚠️ [Warning] Recording file size abnormal: {file_size} bytes")
268
+ else:
269
+ log.info(f"✅ [System] Recording saved ({file_size // 1024} KB)")
270
+ return video_name
271
+ else:
272
+ log.error(f"❌ [Error] Recording file not found: {video_name}")
273
+ return ""
@@ -0,0 +1,24 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BasePlatformAdapter(ABC):
5
+ def __init__(self):
6
+ self.driver = None
7
+
8
+ @abstractmethod
9
+ def setup(self):
10
+ pass
11
+
12
+ @abstractmethod
13
+ def teardown(self):
14
+ pass
15
+
16
+ @abstractmethod
17
+ def take_screenshot(self) -> bytes:
18
+ pass
19
+
20
+ def start_record(self, video_name: str):
21
+ pass
22
+
23
+ def stop_record_and_get_path(self, video_name: str) -> str:
24
+ return ""
@@ -0,0 +1,278 @@
1
+ import io
2
+ import os
3
+ import signal
4
+ import subprocess
5
+ import sys
6
+ import time
7
+
8
+ import config.config as config
9
+ from common.logs import log
10
+
11
+ from .base_adapter import BasePlatformAdapter
12
+
13
+ _SESSION_FILE = os.path.abspath(os.path.join("report", "ios_session.json"))
14
+
15
+
16
+ def _read_session() -> dict | None:
17
+ if not os.path.exists(_SESSION_FILE):
18
+ return None
19
+ try:
20
+ import json
21
+ with open(_SESSION_FILE, "r") as f:
22
+ return json.load(f)
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def _write_session(wda_url: str, udid: str) -> None:
28
+ import json
29
+ os.makedirs(os.path.dirname(_SESSION_FILE), exist_ok=True)
30
+ with open(_SESSION_FILE, "w") as f:
31
+ json.dump({"wda_url": wda_url, "udid": udid}, f)
32
+
33
+
34
+ def _clear_session() -> None:
35
+ if os.path.exists(_SESSION_FILE):
36
+ os.remove(_SESSION_FILE)
37
+
38
+
39
+ def _is_wda_alive(url: str, timeout: float = 3.0) -> bool:
40
+ try:
41
+ import urllib.request
42
+ urllib.request.urlopen(f"{url}/status", timeout=timeout)
43
+ return True
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ def _is_macos() -> bool:
49
+ return sys.platform == "darwin"
50
+
51
+
52
+ def _find_device_udid() -> str:
53
+ if config.IOS_DEVICE_UDID:
54
+ return config.IOS_DEVICE_UDID
55
+ if not _is_macos():
56
+ return ""
57
+ try:
58
+ result = subprocess.run(
59
+ ["xcrun", "simctl", "list", "devices", "booted", "-j"],
60
+ capture_output=True, text=True, timeout=5,
61
+ )
62
+ if result.returncode == 0:
63
+ import json
64
+ data = json.loads(result.stdout)
65
+ for runtime_devices in data.get("devices", {}).values():
66
+ for device in runtime_devices:
67
+ if device.get("state") == "Booted":
68
+ return device.get("udid", "")
69
+ except Exception:
70
+ pass
71
+ return ""
72
+
73
+
74
+ class IosWdaAdapter(BasePlatformAdapter):
75
+
76
+ def __init__(self):
77
+ super().__init__()
78
+ self._wda_url = config.WDA_URL
79
+ self._udid = ""
80
+ self._record_process = None
81
+ self._record_path = ""
82
+
83
+ def setup(self):
84
+ log.info("⏳ [Setup] Initializing iOS (WDA) device...")
85
+
86
+ try:
87
+ import wda
88
+ except ImportError:
89
+ log.error(
90
+ "❌ [E040] facebook-wda not installed. "
91
+ "Fix: pip install screenforge[ios] or pip install facebook-wda"
92
+ )
93
+ raise RuntimeError(
94
+ "facebook-wda not installed. Run: pip install screenforge[ios]"
95
+ )
96
+
97
+ if self._try_reconnect(wda):
98
+ return
99
+
100
+ self._connect_fresh(wda)
101
+
102
+ def _try_reconnect(self, wda_module) -> bool:
103
+ session = _read_session()
104
+ if not session:
105
+ return False
106
+
107
+ url = session.get("wda_url", "")
108
+ if not url or not _is_wda_alive(url):
109
+ log.info("⚠️ [System] Previous WDA session no longer alive, connecting fresh")
110
+ _clear_session()
111
+ return False
112
+
113
+ try:
114
+ self.driver = wda_module.Client(url)
115
+ self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
116
+ status = self.driver.status()
117
+ log.info(
118
+ f"✅ [System] Reconnected to WDA session "
119
+ f"(iOS {status.get('os', {}).get('version', 'unknown')})"
120
+ )
121
+ self._wda_url = url
122
+ self._udid = session.get("udid", "")
123
+ return True
124
+ except Exception as e:
125
+ log.info(f"⚠️ [System] Reconnect failed ({e}), connecting fresh")
126
+ _clear_session()
127
+ return False
128
+
129
+ def _connect_fresh(self, wda_module):
130
+ if not _is_wda_alive(self._wda_url):
131
+ log.error(
132
+ f"❌ [E041] WebDriverAgent not reachable at {self._wda_url}. "
133
+ "Fix: start WDA on the device first. "
134
+ "See: https://github.com/appium/WebDriverAgent"
135
+ )
136
+ raise RuntimeError(
137
+ f"WebDriverAgent not reachable at {self._wda_url}. "
138
+ "Ensure WDA is running on the target device."
139
+ )
140
+
141
+ try:
142
+ self.driver = wda_module.Client(self._wda_url)
143
+ self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
144
+ status = self.driver.status()
145
+ self._udid = _find_device_udid()
146
+ _write_session(self._wda_url, self._udid)
147
+ log.info(
148
+ f"✅ [System] Connected to iOS device via WDA "
149
+ f"(iOS {status.get('os', {}).get('version', 'unknown')}, "
150
+ f"UDID: {self._udid or 'auto'})"
151
+ )
152
+ except Exception as e:
153
+ log.error(f"❌ [E042] Failed to connect to WDA at {self._wda_url}: {e}")
154
+ raise RuntimeError(f"WDA connection failed: {e}")
155
+
156
+ def teardown(self):
157
+ log.info("⏳ [Teardown] Disconnecting iOS device...")
158
+ if self._record_process:
159
+ self.stop_record_and_get_path("")
160
+ if self.driver:
161
+ try:
162
+ self.driver.session().close()
163
+ except Exception:
164
+ pass
165
+ self.driver = None
166
+ log.info("✅ [System] iOS device disconnected")
167
+
168
+ def start_record(self, video_name: str):
169
+ if not _is_macos():
170
+ log.info(
171
+ "⚠️ [System] iOS recording requires macOS with Xcode. "
172
+ "Skipping on this platform."
173
+ )
174
+ return
175
+
176
+ udid = self._udid or _find_device_udid()
177
+ if not udid:
178
+ log.warning(
179
+ "⚠️ [Warning] Cannot determine device UDID for recording. "
180
+ "Fix: export IOS_DEVICE_UDID=<your-udid> or boot a simulator"
181
+ )
182
+ return
183
+
184
+ video_dir = os.path.abspath("report")
185
+ os.makedirs(video_dir, exist_ok=True)
186
+ if not video_name.endswith(".mov"):
187
+ video_name = video_name + ".mov"
188
+ self._record_path = os.path.join(video_dir, video_name)
189
+
190
+ log.info(f"📹 [System] Starting iOS screen recording (UDID: {udid})...")
191
+ try:
192
+ cmd = ["xcrun", "simctl", "io", udid, "recordVideo", self._record_path]
193
+ self._record_process = subprocess.Popen(
194
+ cmd,
195
+ stdout=subprocess.DEVNULL,
196
+ stderr=subprocess.PIPE,
197
+ preexec_fn=os.setsid,
198
+ )
199
+ time.sleep(1.0)
200
+ if self._record_process.poll() is not None:
201
+ stderr = self._record_process.stderr.read().decode() if self._record_process.stderr else ""
202
+ log.error(f"❌ [Error] xcrun simctl recordVideo crashed on startup: {stderr}")
203
+ self._record_process = None
204
+ except FileNotFoundError:
205
+ log.error(
206
+ "❌ [Error] xcrun not found. "
207
+ "Fix: install Xcode Command Line Tools (xcode-select --install)"
208
+ )
209
+ except Exception as e:
210
+ log.error(f"❌ [Error] Failed to start iOS recording: {e}")
211
+
212
+ def stop_record_and_get_path(self, video_name: str = "") -> str:
213
+ if not self._record_process:
214
+ return ""
215
+
216
+ log.info("⏳ [System] Stopping iOS recording...")
217
+ try:
218
+ pgid = os.getpgid(self._record_process.pid)
219
+ os.killpg(pgid, signal.SIGINT)
220
+ self._record_process.wait(timeout=5.0)
221
+ except subprocess.TimeoutExpired:
222
+ log.warning("[Warning] Recording did not stop in time, force killing...")
223
+ try:
224
+ pgid = os.getpgid(self._record_process.pid)
225
+ os.killpg(pgid, signal.SIGKILL)
226
+ except Exception:
227
+ self._record_process.kill()
228
+ self._record_process.wait()
229
+ except OSError:
230
+ try:
231
+ self._record_process.send_signal(signal.SIGINT)
232
+ self._record_process.wait(timeout=2)
233
+ except Exception:
234
+ self._record_process.kill()
235
+ self._record_process.wait()
236
+ except Exception as e:
237
+ log.error(f"❌ [Error] Failed to stop recording: {e}")
238
+
239
+ self._record_process = None
240
+
241
+ actual_path = getattr(self, "_record_path", "")
242
+ if actual_path and os.path.exists(actual_path):
243
+ file_size = os.path.getsize(actual_path)
244
+ if file_size < 1024:
245
+ log.warning(f"⚠️ [Warning] Recording file size abnormal: {file_size} bytes")
246
+ else:
247
+ log.info(f"✅ [System] iOS recording saved ({file_size // 1024} KB)")
248
+ return actual_path
249
+
250
+ return ""
251
+
252
+ def take_screenshot(self, _retry: bool = True) -> bytes:
253
+ if not self.driver:
254
+ log.error("❌ [E043] Cannot take screenshot: no WDA connection")
255
+ return b""
256
+ try:
257
+ image = self.driver.screenshot()
258
+ img_bytes = io.BytesIO()
259
+ image.save(img_bytes, format='PNG')
260
+ return img_bytes.getvalue()
261
+ except Exception as e:
262
+ log.error(f"❌ [E044] Screenshot failed: {e}")
263
+ if _retry and self._attempt_reconnect():
264
+ return self.take_screenshot(_retry=False)
265
+ return b""
266
+
267
+ def _attempt_reconnect(self) -> bool:
268
+ log.info("⚠️ [System] Attempting WDA reconnect...")
269
+ try:
270
+ import wda
271
+ if _is_wda_alive(self._wda_url):
272
+ self.driver = wda.Client(self._wda_url)
273
+ self.driver.implicitly_wait(config.DEFAULT_TIMEOUT)
274
+ log.info("✅ [System] WDA reconnected successfully")
275
+ return True
276
+ except Exception as e:
277
+ log.error(f"❌ [System] Reconnect failed: {e}")
278
+ return False