autoglm-gui 0.4.11__py3-none-any.whl → 0.4.13__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 (38) hide show
  1. AutoGLM_GUI/__init__.py +8 -0
  2. AutoGLM_GUI/__main__.py +29 -34
  3. AutoGLM_GUI/adb_plus/__init__.py +6 -0
  4. AutoGLM_GUI/adb_plus/device.py +50 -0
  5. AutoGLM_GUI/adb_plus/ip.py +78 -0
  6. AutoGLM_GUI/adb_plus/serial.py +35 -0
  7. AutoGLM_GUI/api/__init__.py +10 -1
  8. AutoGLM_GUI/api/agents.py +76 -67
  9. AutoGLM_GUI/api/devices.py +96 -6
  10. AutoGLM_GUI/api/media.py +12 -235
  11. AutoGLM_GUI/api/version.py +192 -0
  12. AutoGLM_GUI/config_manager.py +538 -97
  13. AutoGLM_GUI/exceptions.py +7 -0
  14. AutoGLM_GUI/platform_utils.py +19 -0
  15. AutoGLM_GUI/schemas.py +46 -2
  16. AutoGLM_GUI/scrcpy_protocol.py +46 -0
  17. AutoGLM_GUI/scrcpy_stream.py +192 -307
  18. AutoGLM_GUI/server.py +7 -2
  19. AutoGLM_GUI/socketio_server.py +125 -0
  20. AutoGLM_GUI/static/assets/{about-wSo3UgQ-.js → about-29B5FDM8.js} +1 -1
  21. AutoGLM_GUI/static/assets/chat-DTN2oKtA.js +149 -0
  22. AutoGLM_GUI/static/assets/index-Dy550Qqg.css +1 -0
  23. AutoGLM_GUI/static/assets/{index-B5u1xtK1.js → index-mVNV0VwM.js} +1 -1
  24. AutoGLM_GUI/static/assets/index-wu8Wjf12.js +10 -0
  25. AutoGLM_GUI/static/assets/worker-D6BRitjy.js +1 -0
  26. AutoGLM_GUI/static/index.html +2 -2
  27. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.13.dist-info}/METADATA +25 -2
  28. autoglm_gui-0.4.13.dist-info/RECORD +57 -0
  29. AutoGLM_GUI/resources/apks/ADBKeyBoard.LICENSE.txt +0 -339
  30. AutoGLM_GUI/resources/apks/ADBKeyBoard.README.txt +0 -1
  31. AutoGLM_GUI/resources/apks/ADBKeyboard.apk +0 -0
  32. AutoGLM_GUI/static/assets/chat-BcY2K0yj.js +0 -25
  33. AutoGLM_GUI/static/assets/index-CHrYo3Qj.css +0 -1
  34. AutoGLM_GUI/static/assets/index-D5BALRbT.js +0 -10
  35. autoglm_gui-0.4.11.dist-info/RECORD +0 -52
  36. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.13.dist-info}/WHEEL +0 -0
  37. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.13.dist-info}/entry_points.txt +0 -0
  38. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.13.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/api/media.py CHANGED
@@ -1,52 +1,28 @@
1
- """Media routes: screenshot, video stream, stream reset."""
1
+ """Media routes: screenshot and stream reset."""
2
2
 
3
- import asyncio
4
- import os
5
- from pathlib import Path
3
+ from __future__ import annotations
6
4
 
7
- from fastapi import APIRouter, WebSocket, WebSocketDisconnect
5
+ from fastapi import APIRouter
8
6
 
9
7
  from AutoGLM_GUI.adb_plus import capture_screenshot
10
8
  from AutoGLM_GUI.logger import logger
11
9
  from AutoGLM_GUI.schemas import ScreenshotRequest, ScreenshotResponse
12
- from AutoGLM_GUI.scrcpy_stream import ScrcpyStreamer
13
- from AutoGLM_GUI.state import scrcpy_locks, scrcpy_streamers
10
+ from AutoGLM_GUI.socketio_server import stop_streamers
14
11
 
15
12
  router = APIRouter()
16
13
 
17
- # Debug configuration: Set DEBUG_SAVE_VIDEO_STREAM=1 to save streams to debug_streams/
18
- DEBUG_SAVE_STREAM = os.getenv("DEBUG_SAVE_VIDEO_STREAM", "0") == "1"
19
-
20
14
 
21
15
  @router.post("/api/video/reset")
22
16
  async def reset_video_stream(device_id: str | None = None) -> dict:
23
- """Reset video stream (cleanup scrcpy server,多设备支持)."""
17
+ """Reset active scrcpy streams (Socket.IO)."""
18
+ stop_streamers(device_id=device_id)
24
19
  if device_id:
25
- if device_id in scrcpy_locks:
26
- async with scrcpy_locks[device_id]:
27
- if device_id in scrcpy_streamers:
28
- logger.info(f"Stopping streamer for device {device_id}")
29
- scrcpy_streamers[device_id].stop()
30
- del scrcpy_streamers[device_id]
31
- logger.info(f"Streamer reset for device {device_id}")
32
- return {
33
- "success": True,
34
- "message": f"Video stream reset for device {device_id}",
35
- }
36
- return {
37
- "success": True,
38
- "message": f"No active video stream for device {device_id}",
39
- }
40
- return {"success": True, "message": f"No video stream for device {device_id}"}
41
-
42
- device_ids = list(scrcpy_streamers.keys())
43
- for dev_id in device_ids:
44
- if dev_id in scrcpy_locks:
45
- async with scrcpy_locks[dev_id]:
46
- if dev_id in scrcpy_streamers:
47
- scrcpy_streamers[dev_id].stop()
48
- del scrcpy_streamers[dev_id]
49
- logger.info("All streamers reset")
20
+ logger.info("Video stream reset for device %s", device_id)
21
+ return {
22
+ "success": True,
23
+ "message": f"Video stream reset for device {device_id}",
24
+ }
25
+ logger.info("All video streams reset")
50
26
  return {"success": True, "message": "All video streams reset"}
51
27
 
52
28
 
@@ -71,202 +47,3 @@ def take_screenshot(request: ScreenshotRequest) -> ScreenshotResponse:
71
47
  is_sensitive=False,
72
48
  error=str(e),
73
49
  )
74
-
75
-
76
- @router.websocket("/api/video/stream")
77
- async def video_stream_ws(
78
- websocket: WebSocket,
79
- device_id: str | None = None,
80
- ):
81
- """Stream real-time H.264 video from scrcpy server via WebSocket(多设备支持)."""
82
- await websocket.accept()
83
-
84
- if not device_id:
85
- await websocket.send_json({"error": "device_id is required"})
86
- return
87
-
88
- logger.info(f"WebSocket connection for device {device_id}")
89
-
90
- # Debug: Save stream to file for analysis (controlled by DEBUG_SAVE_VIDEO_STREAM env var)
91
- debug_file = None
92
- if DEBUG_SAVE_STREAM:
93
- debug_dir = Path("debug_streams")
94
- debug_dir.mkdir(exist_ok=True)
95
- debug_file_path = (
96
- debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
97
- )
98
- debug_file = open(debug_file_path, "wb")
99
- logger.debug(f"DEBUG: Saving stream to {debug_file_path}")
100
-
101
- if device_id not in scrcpy_locks:
102
- scrcpy_locks[device_id] = asyncio.Lock()
103
-
104
- async with scrcpy_locks[device_id]:
105
- if device_id not in scrcpy_streamers:
106
- logger.info(f"Creating streamer for device {device_id}")
107
- scrcpy_streamers[device_id] = ScrcpyStreamer(
108
- device_id=device_id, max_size=1280, bit_rate=4_000_000
109
- )
110
-
111
- try:
112
- logger.info(f"Starting scrcpy server for device {device_id}")
113
- await scrcpy_streamers[device_id].start()
114
- logger.info(f"Scrcpy server started for device {device_id}")
115
-
116
- # Read NAL units until we have SPS, PPS, and IDR
117
- streamer = scrcpy_streamers[device_id]
118
-
119
- logger.debug("Reading NAL units for initialization...")
120
- for attempt in range(20): # Max 20 NAL units for initialization
121
- try:
122
- nal_unit = await streamer.read_nal_unit(auto_cache=True)
123
- nal_type = nal_unit[4] & 0x1F if len(nal_unit) > 4 else -1
124
- nal_type_names = {5: "IDR", 7: "SPS", 8: "PPS"}
125
- logger.debug(
126
- f"Read NAL unit: type={nal_type_names.get(nal_type, nal_type)}, size={len(nal_unit)} bytes"
127
- )
128
-
129
- # Check if we have all required parameter sets
130
- if (
131
- streamer.cached_sps
132
- and streamer.cached_pps
133
- and streamer.cached_idr
134
- ):
135
- logger.debug(
136
- f"✓ Initialization complete: SPS={len(streamer.cached_sps)}B, PPS={len(streamer.cached_pps)}B, IDR={len(streamer.cached_idr)}B"
137
- )
138
- break
139
- except Exception as e:
140
- logger.warning(f"Failed to read NAL unit: {e}")
141
- await asyncio.sleep(0.5)
142
- continue
143
-
144
- # Get initialization data (SPS + PPS + IDR)
145
- init_data = streamer.get_initialization_data()
146
- if not init_data:
147
- raise RuntimeError(
148
- "Failed to get initialization data (missing SPS/PPS/IDR)"
149
- )
150
-
151
- # Send initialization data as ONE message (SPS+PPS+IDR combined)
152
- await websocket.send_bytes(init_data)
153
- logger.debug(
154
- f"✓ Sent initialization data to first client: {len(init_data)} bytes total"
155
- )
156
-
157
- # Debug: Save to file
158
- if debug_file:
159
- debug_file.write(init_data)
160
- debug_file.flush()
161
-
162
- except Exception as e:
163
- logger.exception(f"Failed to start streamer: {e}")
164
- scrcpy_streamers[device_id].stop()
165
- del scrcpy_streamers[device_id]
166
- try:
167
- await websocket.send_json({"error": str(e)})
168
- except Exception:
169
- pass
170
- return
171
- else:
172
- logger.info(f"Reusing streamer for device {device_id}")
173
-
174
- streamer = scrcpy_streamers[device_id]
175
- # CRITICAL: Send complete initialization data (SPS+PPS+IDR)
176
- # Without IDR frame, decoder cannot start and will show black screen
177
-
178
- # Wait for initialization data to be ready (max 5 seconds)
179
- init_data = None
180
- for attempt in range(10):
181
- init_data = streamer.get_initialization_data()
182
- if init_data:
183
- break
184
- logger.debug(
185
- f"Waiting for initialization data (attempt {attempt + 1}/10)..."
186
- )
187
- await asyncio.sleep(0.5)
188
-
189
- if init_data:
190
- # Log what we're sending
191
- logger.debug(
192
- f"✓ Sending cached initialization data for device {device_id}:"
193
- )
194
- logger.debug(
195
- f" - SPS: {len(streamer.cached_sps) if streamer.cached_sps else 0}B"
196
- )
197
- logger.debug(
198
- f" - PPS: {len(streamer.cached_pps) if streamer.cached_pps else 0}B"
199
- )
200
- logger.debug(
201
- f" - IDR: {len(streamer.cached_idr) if streamer.cached_idr else 0}B"
202
- )
203
- logger.debug(f" - Total: {len(init_data)} bytes")
204
-
205
- await websocket.send_bytes(init_data)
206
- logger.debug("✓ Initialization data sent successfully")
207
-
208
- # Debug: Save to file
209
- if debug_file:
210
- debug_file.write(init_data)
211
- debug_file.flush()
212
- else:
213
- error_msg = f"Initialization data not ready for device {device_id} after 5 seconds"
214
- logger.error(f"ERROR: {error_msg}")
215
- try:
216
- await websocket.send_json({"error": error_msg})
217
- except Exception:
218
- pass
219
- return
220
-
221
- streamer = scrcpy_streamers[device_id]
222
-
223
- stream_failed = False
224
- try:
225
- nal_count = 0
226
- while True:
227
- try:
228
- # Read one complete NAL unit
229
- # Each WebSocket message = one complete NAL unit (clear semantic boundary)
230
- nal_unit = await streamer.read_nal_unit(auto_cache=True)
231
- await websocket.send_bytes(nal_unit)
232
-
233
- # Debug: Save to file
234
- if debug_file:
235
- debug_file.write(nal_unit)
236
- debug_file.flush()
237
-
238
- nal_count += 1
239
- if nal_count % 100 == 0:
240
- logger.debug(f"Device {device_id}: Sent {nal_count} NAL units")
241
- except ConnectionError as e:
242
- logger.warning(f"Device {device_id}: Connection error: {e}")
243
- stream_failed = True
244
- try:
245
- await websocket.send_json({"error": f"Stream error: {str(e)}"})
246
- except Exception:
247
- pass
248
- break
249
-
250
- except WebSocketDisconnect:
251
- logger.info(f"Device {device_id}: Client disconnected")
252
- except Exception as e:
253
- logger.exception(f"Device {device_id}: Error: {e}")
254
- stream_failed = True
255
- try:
256
- await websocket.send_json({"error": str(e)})
257
- except Exception:
258
- pass
259
-
260
- if stream_failed:
261
- async with scrcpy_locks[device_id]:
262
- if device_id in scrcpy_streamers:
263
- logger.info(f"Resetting streamer for device {device_id}")
264
- scrcpy_streamers[device_id].stop()
265
- del scrcpy_streamers[device_id]
266
-
267
- # Debug: Close file
268
- if debug_file:
269
- debug_file.close()
270
- logger.debug("DEBUG: Closed debug file")
271
-
272
- logger.info(f"Device {device_id}: Stream ended")
@@ -0,0 +1,192 @@
1
+ """Version check API for detecting updates from GitHub Releases."""
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+ from fastapi import APIRouter
10
+
11
+ from AutoGLM_GUI.logger import logger
12
+ from AutoGLM_GUI.schemas import VersionCheckResponse
13
+ from AutoGLM_GUI.version import APP_VERSION
14
+
15
+ router = APIRouter()
16
+
17
+ # In-memory cache for version check results
18
+ _version_cache: dict[str, Any] = {
19
+ "data": None,
20
+ "timestamp": 0,
21
+ "ttl": 3600, # 1 hour cache TTL
22
+ }
23
+
24
+ # GitHub repository information
25
+ GITHUB_REPO = "suyiiyii/AutoGLM-GUI"
26
+ GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
27
+
28
+
29
+ def parse_version(version_str: str) -> tuple[int, ...] | None:
30
+ """
31
+ Parse semantic version string into tuple of integers.
32
+
33
+ Args:
34
+ version_str: Version string like "0.4.12" or "v0.5.0"
35
+
36
+ Returns:
37
+ Tuple of version numbers like (0, 4, 12) or None if invalid
38
+ """
39
+ # Handle dev/unknown versions
40
+ if version_str in ("dev", "unknown", "..."):
41
+ return None
42
+
43
+ # Strip 'v' prefix if present
44
+ version_str = version_str.lstrip("v")
45
+
46
+ # Remove pre-release tags (e.g., "-beta", "-rc1")
47
+ version_str = re.split(r"[-+]", version_str)[0]
48
+
49
+ try:
50
+ return tuple(int(x) for x in version_str.split("."))
51
+ except (ValueError, AttributeError):
52
+ logger.warning(f"Failed to parse version: {version_str}")
53
+ return None
54
+
55
+
56
+ def compare_versions(current: str, latest: str) -> bool:
57
+ """
58
+ Compare two semantic versions.
59
+
60
+ Args:
61
+ current: Current version string
62
+ latest: Latest version string
63
+
64
+ Returns:
65
+ True if update is available (latest > current), False otherwise
66
+ """
67
+ current_tuple = parse_version(current)
68
+ latest_tuple = parse_version(latest)
69
+
70
+ # If either version is invalid, assume no update
71
+ if current_tuple is None or latest_tuple is None:
72
+ return False
73
+
74
+ return latest_tuple > current_tuple
75
+
76
+
77
+ def fetch_latest_release() -> dict[str, Any] | None:
78
+ """
79
+ Fetch latest release information from GitHub API.
80
+
81
+ Returns:
82
+ Release data dict with 'tag_name', 'html_url', 'published_at' or None on error
83
+ """
84
+ try:
85
+ # Create request with User-Agent header (required by GitHub API)
86
+ req = urllib.request.Request(
87
+ GITHUB_API_URL,
88
+ headers={"User-Agent": f"AutoGLM-GUI/{APP_VERSION}"},
89
+ )
90
+
91
+ # Fetch data with 10-second timeout
92
+ with urllib.request.urlopen(req, timeout=10) as response:
93
+ data = json.loads(response.read().decode("utf-8"))
94
+ logger.debug(
95
+ f"Successfully fetched latest release: {data.get('tag_name', 'unknown')}"
96
+ )
97
+ return data
98
+
99
+ except urllib.error.HTTPError as e:
100
+ if e.code == 403:
101
+ logger.warning(
102
+ "GitHub API rate limit exceeded (HTTP 403), using cached data if available"
103
+ )
104
+ else:
105
+ logger.warning(f"GitHub API HTTP error {e.code}: {e.reason}")
106
+ return None
107
+
108
+ except urllib.error.URLError as e:
109
+ logger.warning(f"Network error fetching latest release: {e.reason}")
110
+ return None
111
+
112
+ except json.JSONDecodeError as e:
113
+ logger.error(f"Failed to parse GitHub API response: {e}")
114
+ return None
115
+
116
+ except Exception as e:
117
+ logger.error(f"Unexpected error fetching latest release: {e}")
118
+ return None
119
+
120
+
121
+ @router.get("/api/version/latest", response_model=VersionCheckResponse)
122
+ def check_version() -> VersionCheckResponse:
123
+ """
124
+ Check for available updates from GitHub Releases.
125
+
126
+ Returns version comparison results with caching to minimize API calls.
127
+ Cache TTL is 1 hour to stay within GitHub API rate limits (60 req/hour).
128
+
129
+ Returns:
130
+ VersionCheckResponse with update information
131
+ """
132
+ current_time = time.time()
133
+
134
+ # Check if cache is still valid
135
+ if (
136
+ _version_cache["data"] is not None
137
+ and current_time - _version_cache["timestamp"] < _version_cache["ttl"]
138
+ ):
139
+ logger.debug(
140
+ f"Using cached version check result (age: {int(current_time - _version_cache['timestamp'])}s)"
141
+ )
142
+ return _version_cache["data"]
143
+
144
+ # Fetch latest release from GitHub
145
+ release_data = fetch_latest_release()
146
+
147
+ if release_data is None:
148
+ # If fetch failed, check if we have cached data to fall back to
149
+ if _version_cache["data"] is not None:
150
+ logger.warning("Using stale cached data due to fetch failure")
151
+ return _version_cache["data"]
152
+
153
+ # No cache available, return safe defaults
154
+ response = VersionCheckResponse(
155
+ current_version=APP_VERSION,
156
+ latest_version=None,
157
+ has_update=False,
158
+ release_url=None,
159
+ published_at=None,
160
+ error="Failed to fetch latest version from GitHub",
161
+ )
162
+ logger.info("Version check failed, returning safe defaults")
163
+ return response
164
+
165
+ # Extract version information from release data
166
+ tag_name = release_data.get("tag_name", "")
167
+ latest_version = tag_name.lstrip("v") # Strip 'v' prefix
168
+ release_url = release_data.get("html_url")
169
+ published_at = release_data.get("published_at")
170
+
171
+ # Compare versions
172
+ has_update = compare_versions(APP_VERSION, latest_version)
173
+
174
+ # Build response
175
+ response = VersionCheckResponse(
176
+ current_version=APP_VERSION,
177
+ latest_version=latest_version,
178
+ has_update=has_update,
179
+ release_url=release_url,
180
+ published_at=published_at,
181
+ error=None,
182
+ )
183
+
184
+ # Update cache
185
+ _version_cache["data"] = response
186
+ _version_cache["timestamp"] = current_time
187
+
188
+ logger.info(
189
+ f"Version check completed: current={APP_VERSION}, latest={latest_version}, has_update={has_update}"
190
+ )
191
+
192
+ return response