autoglm-gui 0.1.8__tar.gz → 0.1.10__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.
@@ -16,3 +16,4 @@ AutoGLM_GUI/static/
16
16
  # Frontend
17
17
  frontend/node_modules/
18
18
  frontend/dist/
19
+ .mcp.json
@@ -0,0 +1,5 @@
1
+ """Lightweight ADB helpers with a more robust screenshot implementation."""
2
+
3
+ from .screenshot import Screenshot, capture_screenshot
4
+
5
+ __all__ = ["Screenshot", "capture_screenshot"]
@@ -0,0 +1,115 @@
1
+ """Robust screenshot helper using `adb exec-out screencap -p`.
2
+
3
+ Features:
4
+ - Avoids temp files and uses exec-out to reduce corruption.
5
+ - Normalizes CRLF issues from some devices.
6
+ - Validates PNG signature/size and retries before falling back.
7
+ """
8
+
9
+ import base64
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from io import BytesIO
13
+ from typing import Iterable
14
+
15
+ from PIL import Image
16
+
17
+
18
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
19
+
20
+
21
+ @dataclass
22
+ class Screenshot:
23
+ """Represents a captured screenshot."""
24
+
25
+ base64_data: str
26
+ width: int
27
+ height: int
28
+ is_sensitive: bool = False
29
+
30
+
31
+ def capture_screenshot(
32
+ device_id: str | None = None,
33
+ adb_path: str = "adb",
34
+ timeout: int = 10,
35
+ retries: int = 1,
36
+ ) -> Screenshot:
37
+ """
38
+ Capture a screenshot using adb exec-out.
39
+
40
+ Args:
41
+ device_id: Optional device serial.
42
+ adb_path: Path to adb binary.
43
+ timeout: Per-attempt timeout in seconds.
44
+ retries: Extra attempts after the first try.
45
+
46
+ Returns:
47
+ Screenshot object; falls back to a black image on failure.
48
+ """
49
+ attempts = max(1, retries + 1)
50
+ for _ in range(attempts):
51
+ data = _try_capture(device_id=device_id, adb_path=adb_path, timeout=timeout)
52
+ if not data:
53
+ continue
54
+
55
+ # NOTE: Do NOT do CRLF normalization for binary PNG data from exec-out
56
+ # The PNG signature contains \r\n bytes that must be preserved
57
+
58
+ if not _is_valid_png(data):
59
+ continue
60
+
61
+ try:
62
+ img = Image.open(BytesIO(data))
63
+ width, height = img.size
64
+ buffered = BytesIO()
65
+ img.save(buffered, format="PNG")
66
+ base64_data = base64.b64encode(buffered.getvalue()).decode("utf-8")
67
+ return Screenshot(base64_data=base64_data, width=width, height=height)
68
+ except Exception:
69
+ # Try next attempt
70
+ continue
71
+
72
+ return _fallback_screenshot()
73
+
74
+
75
+ def _try_capture(
76
+ device_id: str | None, adb_path: str, timeout: int
77
+ ) -> bytes | None:
78
+ """Run exec-out screencap and return raw bytes or None on failure."""
79
+ cmd: list[str | bytes] = [adb_path]
80
+ if device_id:
81
+ cmd.extend(["-s", device_id])
82
+ cmd.extend(["exec-out", "screencap", "-p"])
83
+
84
+ try:
85
+ result = subprocess.run(
86
+ cmd,
87
+ capture_output=True,
88
+ timeout=timeout,
89
+ )
90
+ if result.returncode != 0:
91
+ return None
92
+ # stdout should hold the PNG data
93
+ return result.stdout if isinstance(result.stdout, (bytes, bytearray)) else None
94
+ except Exception:
95
+ return None
96
+
97
+
98
+ def _is_valid_png(data: bytes) -> bool:
99
+ """Basic PNG validation (signature + minimal length)."""
100
+ return (
101
+ len(data) > len(PNG_SIGNATURE) + 8 # header + IHDR length
102
+ and data.startswith(PNG_SIGNATURE)
103
+ )
104
+
105
+
106
+ def _fallback_screenshot() -> Screenshot:
107
+ """Return a black fallback image."""
108
+ width, height = 1080, 2400
109
+ img = Image.new("RGB", (width, height), color="black")
110
+ buffered = BytesIO()
111
+ img.save(buffered, format="PNG")
112
+ base64_data = base64.b64encode(buffered.getvalue()).decode("utf-8")
113
+ return Screenshot(
114
+ base64_data=base64_data, width=width, height=height, is_sensitive=False
115
+ )
@@ -0,0 +1,499 @@
1
+ """scrcpy video streaming implementation."""
2
+
3
+ import asyncio
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ class ScrcpyStreamer:
11
+ """Manages scrcpy server lifecycle and H.264 video streaming."""
12
+
13
+ def __init__(
14
+ self,
15
+ device_id: str | None = None,
16
+ max_size: int = 1280,
17
+ bit_rate: int = 1_000_000,
18
+ port: int = 27183,
19
+ ):
20
+ """Initialize ScrcpyStreamer.
21
+
22
+ Args:
23
+ device_id: ADB device serial (None for default device)
24
+ max_size: Maximum video dimension
25
+ bit_rate: Video bitrate in bps
26
+ port: TCP port for scrcpy socket
27
+ """
28
+ self.device_id = device_id
29
+ self.max_size = max_size
30
+ self.bit_rate = bit_rate
31
+ self.port = port
32
+
33
+ self.scrcpy_process: subprocess.Popen | None = None
34
+ self.tcp_socket: socket.socket | None = None
35
+ self.forward_cleanup_needed = False
36
+
37
+ # H.264 parameter sets cache (for new connections to join mid-stream)
38
+ # IMPORTANT: Only cache INITIAL complete SPS/PPS from stream start
39
+ # Later SPS/PPS may be truncated across chunks
40
+ self.cached_sps: bytes | None = None
41
+ self.cached_pps: bytes | None = None
42
+ self.cached_idr: bytes | None = None # Last IDR frame for immediate playback
43
+ self.sps_pps_locked = False # Lock SPS/PPS after initial complete capture
44
+ # Note: IDR is NOT locked - we keep updating to the latest frame
45
+
46
+ # Find scrcpy-server location
47
+ self.scrcpy_server_path = self._find_scrcpy_server()
48
+
49
+ def _find_scrcpy_server(self) -> str:
50
+ """Find scrcpy-server binary path."""
51
+ # Priority 1: Project root directory (for repository version)
52
+ project_root = Path(__file__).parent.parent
53
+ project_server = project_root / "scrcpy-server-v3.3.3"
54
+ if project_server.exists():
55
+ print(f"[ScrcpyStreamer] Using project scrcpy-server: {project_server}")
56
+ return str(project_server)
57
+
58
+ # Priority 2: Environment variable
59
+ scrcpy_server = os.getenv("SCRCPY_SERVER_PATH")
60
+ if scrcpy_server and os.path.exists(scrcpy_server):
61
+ print(f"[ScrcpyStreamer] Using env scrcpy-server: {scrcpy_server}")
62
+ return scrcpy_server
63
+
64
+ # Priority 3: Common system locations
65
+ paths = [
66
+ "/opt/homebrew/Cellar/scrcpy/3.3.3/share/scrcpy/scrcpy-server",
67
+ "/usr/local/share/scrcpy/scrcpy-server",
68
+ "/usr/share/scrcpy/scrcpy-server",
69
+ ]
70
+
71
+ for path in paths:
72
+ if os.path.exists(path):
73
+ print(f"[ScrcpyStreamer] Using system scrcpy-server: {path}")
74
+ return path
75
+
76
+ raise FileNotFoundError(
77
+ "scrcpy-server not found. Please put scrcpy-server-v3.3.3 in project root or set SCRCPY_SERVER_PATH."
78
+ )
79
+
80
+ async def start(self) -> None:
81
+ """Start scrcpy server and establish connection."""
82
+ try:
83
+ # 0. Kill existing scrcpy server processes on device
84
+ print("[ScrcpyStreamer] Cleaning up existing scrcpy processes...")
85
+ await self._cleanup_existing_server()
86
+
87
+ # 1. Push scrcpy-server to device
88
+ print("[ScrcpyStreamer] Pushing server to device...")
89
+ await self._push_server()
90
+
91
+ # 2. Setup port forwarding
92
+ print(f"[ScrcpyStreamer] Setting up port forwarding on port {self.port}...")
93
+ await self._setup_port_forward()
94
+
95
+ # 3. Start scrcpy server
96
+ print("[ScrcpyStreamer] Starting scrcpy server...")
97
+ await self._start_server()
98
+
99
+ # 4. Connect TCP socket
100
+ print("[ScrcpyStreamer] Connecting to TCP socket...")
101
+ await self._connect_socket()
102
+ print("[ScrcpyStreamer] Successfully connected!")
103
+
104
+ except Exception as e:
105
+ print(f"[ScrcpyStreamer] Failed to start: {e}")
106
+ import traceback
107
+ traceback.print_exc()
108
+ self.stop()
109
+ raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
110
+
111
+ async def _cleanup_existing_server(self) -> None:
112
+ """Kill existing scrcpy server processes on device."""
113
+ cmd_base = ["adb"]
114
+ if self.device_id:
115
+ cmd_base.extend(["-s", self.device_id])
116
+
117
+ # Method 1: Try pkill
118
+ cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
119
+ process = await asyncio.create_subprocess_exec(
120
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
121
+ )
122
+ await process.wait()
123
+
124
+ # Method 2: Find and kill by PID (more reliable)
125
+ cmd = cmd_base + [
126
+ "shell",
127
+ "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9"
128
+ ]
129
+ process = await asyncio.create_subprocess_exec(
130
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
131
+ )
132
+ await process.wait()
133
+
134
+ # Method 3: Remove port forward if exists
135
+ cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
136
+ process = await asyncio.create_subprocess_exec(
137
+ *cmd_remove_forward, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
138
+ )
139
+ await process.wait()
140
+
141
+ # Wait longer for resources to be released
142
+ print("[ScrcpyStreamer] Waiting for cleanup to complete...")
143
+ await asyncio.sleep(2)
144
+
145
+ async def _push_server(self) -> None:
146
+ """Push scrcpy-server to device."""
147
+ cmd = ["adb"]
148
+ if self.device_id:
149
+ cmd.extend(["-s", self.device_id])
150
+ cmd.extend(["push", self.scrcpy_server_path, "/data/local/tmp/scrcpy-server"])
151
+
152
+ process = await asyncio.create_subprocess_exec(
153
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
154
+ )
155
+ await process.wait()
156
+
157
+ async def _setup_port_forward(self) -> None:
158
+ """Setup ADB port forwarding."""
159
+ cmd = ["adb"]
160
+ if self.device_id:
161
+ cmd.extend(["-s", self.device_id])
162
+ cmd.extend(["forward", f"tcp:{self.port}", "localabstract:scrcpy"])
163
+
164
+ process = await asyncio.create_subprocess_exec(
165
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
166
+ )
167
+ await process.wait()
168
+ self.forward_cleanup_needed = True
169
+
170
+ async def _start_server(self) -> None:
171
+ """Start scrcpy server on device with retry on address conflict."""
172
+ max_retries = 3
173
+ retry_delay = 2
174
+
175
+ for attempt in range(max_retries):
176
+ cmd = ["adb"]
177
+ if self.device_id:
178
+ cmd.extend(["-s", self.device_id])
179
+
180
+ # Build server command
181
+ # Note: scrcpy 3.3+ uses different parameter format
182
+ server_args = [
183
+ "shell",
184
+ "CLASSPATH=/data/local/tmp/scrcpy-server",
185
+ "app_process",
186
+ "/",
187
+ "com.genymobile.scrcpy.Server",
188
+ "3.3.3", # scrcpy version - must match installed version
189
+ f"max_size={self.max_size}",
190
+ f"video_bit_rate={self.bit_rate}",
191
+ "max_fps=20", # ✅ Limit to 20fps to reduce data volume
192
+ "tunnel_forward=true",
193
+ "audio=false",
194
+ "control=false",
195
+ "cleanup=false",
196
+ # Force I-frame (IDR) every 1 second for reliable reconnection
197
+ "video_codec_options=i-frame-interval=1",
198
+ ]
199
+ cmd.extend(server_args)
200
+
201
+ # Capture stderr to see error messages
202
+ self.scrcpy_process = await asyncio.create_subprocess_exec(
203
+ *cmd,
204
+ stdout=subprocess.PIPE,
205
+ stderr=subprocess.PIPE
206
+ )
207
+
208
+ # Wait for server to start
209
+ await asyncio.sleep(2)
210
+
211
+ # Check if process is still running
212
+ if self.scrcpy_process.returncode is not None:
213
+ # Process has exited
214
+ stdout, stderr = await self.scrcpy_process.communicate()
215
+ error_msg = stderr.decode() if stderr else stdout.decode()
216
+
217
+ # Check if it's an "Address already in use" error
218
+ if "Address already in use" in error_msg:
219
+ if attempt < max_retries - 1:
220
+ print(f"[ScrcpyStreamer] Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...")
221
+ await self._cleanup_existing_server()
222
+ await asyncio.sleep(retry_delay)
223
+ continue
224
+ else:
225
+ raise RuntimeError(f"scrcpy server failed after {max_retries} attempts: {error_msg}")
226
+ else:
227
+ raise RuntimeError(f"scrcpy server exited immediately: {error_msg}")
228
+
229
+ # Server started successfully
230
+ return
231
+
232
+ raise RuntimeError("Failed to start scrcpy server after maximum retries")
233
+
234
+ async def _connect_socket(self) -> None:
235
+ """Connect to scrcpy TCP socket."""
236
+ self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
237
+ self.tcp_socket.settimeout(5)
238
+
239
+ # Increase socket buffer size for high-resolution video
240
+ # Default is often 64KB, but complex frames can be 200-500KB
241
+ try:
242
+ self.tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024) # 2MB
243
+ print("[ScrcpyStreamer] Set socket receive buffer to 2MB")
244
+ except OSError as e:
245
+ print(f"[ScrcpyStreamer] Warning: Failed to set socket buffer size: {e}")
246
+
247
+ # Retry connection
248
+ for _ in range(5):
249
+ try:
250
+ self.tcp_socket.connect(("localhost", self.port))
251
+ self.tcp_socket.settimeout(None) # Non-blocking for async
252
+ return
253
+ except (ConnectionRefusedError, OSError):
254
+ await asyncio.sleep(0.5)
255
+
256
+ raise ConnectionError("Failed to connect to scrcpy server")
257
+
258
+ def _find_nal_units(self, data: bytes) -> list[tuple[int, int, int]]:
259
+ """Find NAL units in H.264 data.
260
+
261
+ Returns:
262
+ List of (start_pos, nal_type, nal_size) tuples
263
+ """
264
+ nal_units = []
265
+ i = 0
266
+ data_len = len(data)
267
+
268
+ while i < data_len - 4:
269
+ # Look for start codes: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
270
+ if data[i:i+4] == b'\x00\x00\x00\x01':
271
+ start_code_len = 4
272
+ elif data[i:i+3] == b'\x00\x00\x01':
273
+ start_code_len = 3
274
+ else:
275
+ i += 1
276
+ continue
277
+
278
+ # NAL unit type is in lower 5 bits of first byte after start code
279
+ nal_start = i + start_code_len
280
+ if nal_start >= data_len:
281
+ break
282
+
283
+ nal_type = data[nal_start] & 0x1F
284
+
285
+ # Find next start code to determine NAL unit size
286
+ next_start = nal_start + 1
287
+ while next_start < data_len - 3:
288
+ if (data[next_start:next_start+4] == b'\x00\x00\x00\x01' or
289
+ data[next_start:next_start+3] == b'\x00\x00\x01'):
290
+ break
291
+ next_start += 1
292
+ else:
293
+ next_start = data_len
294
+
295
+ nal_size = next_start - i
296
+ nal_units.append((i, nal_type, nal_size))
297
+
298
+ i = next_start
299
+
300
+ return nal_units
301
+
302
+ def _cache_nal_units(self, data: bytes) -> None:
303
+ """Parse and cache INITIAL complete NAL units (SPS, PPS, IDR).
304
+
305
+ IMPORTANT: Only caches complete SPS/PPS from stream start.
306
+ NAL units may be truncated across chunks, so we validate minimum sizes
307
+ and lock the cache after getting complete initial parameters.
308
+ """
309
+ nal_units = self._find_nal_units(data)
310
+
311
+ for start, nal_type, size in nal_units:
312
+ nal_data = data[start:start+size]
313
+
314
+ if nal_type == 7: # SPS
315
+ # Only cache SPS if not yet locked
316
+ if not self.sps_pps_locked:
317
+ # Validate: SPS should be at least 10 bytes
318
+ if size >= 10 and not self.cached_sps:
319
+ self.cached_sps = nal_data
320
+ hex_preview = ' '.join(f'{b:02x}' for b in nal_data[:min(12, len(nal_data))])
321
+ print(f"[ScrcpyStreamer] ✓ Cached complete SPS ({size} bytes): {hex_preview}...")
322
+ elif size < 10:
323
+ print(f"[ScrcpyStreamer] ✗ Skipped truncated SPS ({size} bytes, too short)")
324
+
325
+ elif nal_type == 8: # PPS
326
+ # Only cache PPS if not yet locked
327
+ if not self.sps_pps_locked:
328
+ # Validate: PPS should be at least 6 bytes
329
+ if size >= 6 and not self.cached_pps:
330
+ self.cached_pps = nal_data
331
+ hex_preview = ' '.join(f'{b:02x}' for b in nal_data[:min(12, len(nal_data))])
332
+ print(f"[ScrcpyStreamer] ✓ Cached complete PPS ({size} bytes): {hex_preview}...")
333
+ elif size < 6:
334
+ print(f"[ScrcpyStreamer] ✗ Skipped truncated PPS ({size} bytes, too short)")
335
+
336
+ elif nal_type == 5: # IDR frame
337
+ # ✅ ALWAYS update IDR to keep the LATEST frame
338
+ # This gives better UX on reconnect (recent content, not stale startup frame)
339
+ if self.cached_sps and self.cached_pps:
340
+ is_first = self.cached_idr is None
341
+ self.cached_idr = nal_data
342
+ if is_first:
343
+ print(f"[ScrcpyStreamer] ✓ Cached initial IDR frame ({size} bytes)")
344
+ # Don't log every IDR update (too verbose)
345
+
346
+ # Lock SPS/PPS once we have complete initial parameters
347
+ if self.cached_sps and self.cached_pps and not self.sps_pps_locked:
348
+ self.sps_pps_locked = True
349
+ print("[ScrcpyStreamer] 🔒 SPS/PPS locked (IDR will continue updating)")
350
+
351
+ def _prepend_sps_pps_to_idr(self, data: bytes) -> bytes:
352
+ """Prepend SPS/PPS before EVERY IDR frame unconditionally.
353
+
354
+ This ensures that clients can start decoding from any IDR frame,
355
+ even if they join mid-stream. We always prepend to guarantee
356
+ that every IDR is self-contained.
357
+
358
+ Returns:
359
+ Modified data with SPS/PPS prepended to all IDR frames
360
+ """
361
+ if not self.cached_sps or not self.cached_pps:
362
+ return data
363
+
364
+ nal_units = self._find_nal_units(data)
365
+ if not nal_units:
366
+ return data
367
+
368
+ # Find all IDR frames
369
+ idr_positions = [(start, size) for start, nal_type, size in nal_units if nal_type == 5]
370
+
371
+ if not idr_positions:
372
+ return data
373
+
374
+ # Build modified data by prepending SPS/PPS before each IDR
375
+ result = bytearray()
376
+ last_pos = 0
377
+ sps_pps = self.cached_sps + self.cached_pps
378
+
379
+ for idr_start, idr_size in idr_positions:
380
+ # Add data before this IDR
381
+ result.extend(data[last_pos:idr_start])
382
+
383
+ # Check if SPS/PPS already exists right before this IDR
384
+ # (to avoid duplicating if scrcpy already sent them)
385
+ prepend_offset = max(0, idr_start - len(sps_pps))
386
+ if data[prepend_offset:idr_start] != sps_pps:
387
+ # Prepend SPS/PPS before this IDR
388
+ result.extend(sps_pps)
389
+ print(f"[ScrcpyStreamer] Prepended SPS/PPS before IDR at position {idr_start}")
390
+
391
+ # Update position to start of IDR
392
+ last_pos = idr_start
393
+
394
+ # Add remaining data (including all IDR frames and data after)
395
+ result.extend(data[last_pos:])
396
+
397
+ return bytes(result)
398
+
399
+ def get_initialization_data(self) -> bytes | None:
400
+ """Get cached SPS/PPS/IDR for initializing new connections.
401
+
402
+ Returns:
403
+ Concatenated SPS + PPS + IDR, or None if not available
404
+ """
405
+ if self.cached_sps and self.cached_pps:
406
+ # Return SPS + PPS (+ IDR if available)
407
+ init_data = self.cached_sps + self.cached_pps
408
+ if self.cached_idr:
409
+ init_data += self.cached_idr
410
+
411
+ # Validate data integrity
412
+ print(f"[ScrcpyStreamer] Returning init data:")
413
+ print(f" - SPS: {len(self.cached_sps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_sps[:8])}")
414
+ print(f" - PPS: {len(self.cached_pps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_pps[:8])}")
415
+ if self.cached_idr:
416
+ print(f" - IDR: {len(self.cached_idr)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_idr[:8])}")
417
+ print(f" - Total: {len(init_data)} bytes")
418
+
419
+ return init_data
420
+ return None
421
+
422
+ async def read_h264_chunk(self) -> bytes:
423
+ """Read H.264 data chunk from socket.
424
+
425
+ Returns:
426
+ bytes: Raw H.264 data with SPS/PPS prepended to IDR frames
427
+
428
+ Raises:
429
+ ConnectionError: If socket is closed or error occurs
430
+ """
431
+ if not self.tcp_socket:
432
+ raise ConnectionError("Socket not connected")
433
+
434
+ try:
435
+ # Use asyncio to make socket read non-blocking
436
+ # Read up to 512KB at once for high-quality frames
437
+ loop = asyncio.get_event_loop()
438
+ data = await loop.run_in_executor(None, self.tcp_socket.recv, 512 * 1024)
439
+
440
+ if not data:
441
+ raise ConnectionError("Socket closed by remote")
442
+
443
+ # Log large chunks (might indicate complex frames)
444
+ if len(data) > 200 * 1024: # > 200KB
445
+ print(f"[ScrcpyStreamer] Large chunk received: {len(data) / 1024:.1f} KB")
446
+
447
+ # Cache INITIAL complete SPS/PPS/IDR for future use
448
+ # (Later chunks may have truncated NAL units, so we only cache once)
449
+ self._cache_nal_units(data)
450
+
451
+ # NOTE: We don't automatically prepend SPS/PPS here because:
452
+ # 1. NAL units may be truncated across chunks
453
+ # 2. Prepending truncated SPS/PPS causes decoding errors
454
+ # 3. Instead, we send cached complete SPS/PPS when new connections join
455
+
456
+ return data
457
+ except ConnectionError:
458
+ raise
459
+ except Exception as e:
460
+ print(f"[ScrcpyStreamer] Unexpected error in read_h264_chunk: {type(e).__name__}: {e}")
461
+ raise ConnectionError(f"Failed to read from socket: {e}") from e
462
+
463
+ def stop(self) -> None:
464
+ """Stop scrcpy server and cleanup resources."""
465
+ # Close socket
466
+ if self.tcp_socket:
467
+ try:
468
+ self.tcp_socket.close()
469
+ except Exception:
470
+ pass
471
+ self.tcp_socket = None
472
+
473
+ # Kill server process
474
+ if self.scrcpy_process:
475
+ try:
476
+ self.scrcpy_process.terminate()
477
+ self.scrcpy_process.wait(timeout=2)
478
+ except Exception:
479
+ try:
480
+ self.scrcpy_process.kill()
481
+ except Exception:
482
+ pass
483
+ self.scrcpy_process = None
484
+
485
+ # Remove port forwarding
486
+ if self.forward_cleanup_needed:
487
+ try:
488
+ cmd = ["adb"]
489
+ if self.device_id:
490
+ cmd.extend(["-s", self.device_id])
491
+ cmd.extend(["forward", "--remove", f"tcp:{self.port}"])
492
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2)
493
+ except Exception:
494
+ pass
495
+ self.forward_cleanup_needed = False
496
+
497
+ def __del__(self):
498
+ """Cleanup on destruction."""
499
+ self.stop()