autoglm-gui 1.4.0__py3-none-any.whl → 1.5.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 (120) hide show
  1. AutoGLM_GUI/__init__.py +11 -0
  2. AutoGLM_GUI/__main__.py +26 -8
  3. AutoGLM_GUI/actions/__init__.py +6 -0
  4. AutoGLM_GUI/actions/handler.py +196 -0
  5. AutoGLM_GUI/actions/types.py +15 -0
  6. AutoGLM_GUI/adb/__init__.py +53 -0
  7. AutoGLM_GUI/adb/apps.py +227 -0
  8. AutoGLM_GUI/adb/connection.py +323 -0
  9. AutoGLM_GUI/adb/device.py +171 -0
  10. AutoGLM_GUI/adb/input.py +67 -0
  11. AutoGLM_GUI/adb/screenshot.py +11 -0
  12. AutoGLM_GUI/adb/timing.py +167 -0
  13. AutoGLM_GUI/adb_plus/keyboard_installer.py +4 -2
  14. AutoGLM_GUI/adb_plus/qr_pair.py +8 -8
  15. AutoGLM_GUI/adb_plus/screenshot.py +22 -1
  16. AutoGLM_GUI/adb_plus/serial.py +38 -20
  17. AutoGLM_GUI/adb_plus/touch.py +4 -9
  18. AutoGLM_GUI/agents/__init__.py +51 -0
  19. AutoGLM_GUI/agents/events.py +19 -0
  20. AutoGLM_GUI/agents/factory.py +153 -0
  21. AutoGLM_GUI/agents/glm/__init__.py +7 -0
  22. AutoGLM_GUI/agents/glm/agent.py +292 -0
  23. AutoGLM_GUI/agents/glm/message_builder.py +81 -0
  24. AutoGLM_GUI/agents/glm/parser.py +110 -0
  25. AutoGLM_GUI/agents/glm/prompts_en.py +77 -0
  26. AutoGLM_GUI/agents/glm/prompts_zh.py +75 -0
  27. AutoGLM_GUI/agents/mai/__init__.py +28 -0
  28. AutoGLM_GUI/agents/mai/agent.py +405 -0
  29. AutoGLM_GUI/agents/mai/parser.py +254 -0
  30. AutoGLM_GUI/agents/mai/prompts.py +103 -0
  31. AutoGLM_GUI/agents/mai/traj_memory.py +91 -0
  32. AutoGLM_GUI/agents/protocols.py +27 -0
  33. AutoGLM_GUI/agents/stream_runner.py +188 -0
  34. AutoGLM_GUI/api/__init__.py +71 -11
  35. AutoGLM_GUI/api/agents.py +190 -229
  36. AutoGLM_GUI/api/control.py +9 -6
  37. AutoGLM_GUI/api/devices.py +112 -28
  38. AutoGLM_GUI/api/health.py +13 -0
  39. AutoGLM_GUI/api/history.py +78 -0
  40. AutoGLM_GUI/api/layered_agent.py +306 -181
  41. AutoGLM_GUI/api/mcp.py +11 -10
  42. AutoGLM_GUI/api/media.py +64 -1
  43. AutoGLM_GUI/api/scheduled_tasks.py +98 -0
  44. AutoGLM_GUI/api/version.py +23 -10
  45. AutoGLM_GUI/api/workflows.py +2 -1
  46. AutoGLM_GUI/config.py +72 -14
  47. AutoGLM_GUI/config_manager.py +98 -27
  48. AutoGLM_GUI/device_adapter.py +263 -0
  49. AutoGLM_GUI/device_manager.py +248 -29
  50. AutoGLM_GUI/device_protocol.py +266 -0
  51. AutoGLM_GUI/devices/__init__.py +49 -0
  52. AutoGLM_GUI/devices/adb_device.py +200 -0
  53. AutoGLM_GUI/devices/mock_device.py +185 -0
  54. AutoGLM_GUI/devices/remote_device.py +177 -0
  55. AutoGLM_GUI/exceptions.py +3 -3
  56. AutoGLM_GUI/history_manager.py +164 -0
  57. AutoGLM_GUI/i18n.py +81 -0
  58. AutoGLM_GUI/metrics.py +13 -20
  59. AutoGLM_GUI/model/__init__.py +5 -0
  60. AutoGLM_GUI/model/message_builder.py +69 -0
  61. AutoGLM_GUI/model/types.py +24 -0
  62. AutoGLM_GUI/models/__init__.py +10 -0
  63. AutoGLM_GUI/models/history.py +96 -0
  64. AutoGLM_GUI/models/scheduled_task.py +71 -0
  65. AutoGLM_GUI/parsers/__init__.py +22 -0
  66. AutoGLM_GUI/parsers/base.py +50 -0
  67. AutoGLM_GUI/parsers/phone_parser.py +58 -0
  68. AutoGLM_GUI/phone_agent_manager.py +118 -367
  69. AutoGLM_GUI/platform_utils.py +31 -2
  70. AutoGLM_GUI/prompt_config.py +15 -0
  71. AutoGLM_GUI/prompts/__init__.py +32 -0
  72. AutoGLM_GUI/scheduler_manager.py +304 -0
  73. AutoGLM_GUI/schemas.py +272 -63
  74. AutoGLM_GUI/scrcpy_stream.py +159 -37
  75. AutoGLM_GUI/server.py +3 -1
  76. AutoGLM_GUI/socketio_server.py +114 -29
  77. AutoGLM_GUI/state.py +10 -30
  78. AutoGLM_GUI/static/assets/{about-DeclntHg.js → about-BQm96DAl.js} +1 -1
  79. AutoGLM_GUI/static/assets/alert-dialog-B42XxGPR.js +1 -0
  80. AutoGLM_GUI/static/assets/chat-C0L2gQYG.js +129 -0
  81. AutoGLM_GUI/static/assets/circle-alert-D4rSJh37.js +1 -0
  82. AutoGLM_GUI/static/assets/dialog-DZ78cEcj.js +45 -0
  83. AutoGLM_GUI/static/assets/history-DFBv7TGc.js +1 -0
  84. AutoGLM_GUI/static/assets/index-Bzyv2yQ2.css +1 -0
  85. AutoGLM_GUI/static/assets/{index-zQ4KKDHt.js → index-CmZSnDqc.js} +1 -1
  86. AutoGLM_GUI/static/assets/index-CssG-3TH.js +11 -0
  87. AutoGLM_GUI/static/assets/label-BCUzE_nm.js +1 -0
  88. AutoGLM_GUI/static/assets/logs-eoFxn5of.js +1 -0
  89. AutoGLM_GUI/static/assets/popover-DLsuV5Sx.js +1 -0
  90. AutoGLM_GUI/static/assets/scheduled-tasks-MyqGJvy_.js +1 -0
  91. AutoGLM_GUI/static/assets/square-pen-zGWYrdfj.js +1 -0
  92. AutoGLM_GUI/static/assets/textarea-BX6y7uM5.js +1 -0
  93. AutoGLM_GUI/static/assets/workflows-CYFs6ssC.js +1 -0
  94. AutoGLM_GUI/static/index.html +2 -2
  95. AutoGLM_GUI/types.py +142 -0
  96. {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/METADATA +178 -92
  97. autoglm_gui-1.5.0.dist-info/RECORD +157 -0
  98. mai_agent/base.py +137 -0
  99. mai_agent/mai_grounding_agent.py +263 -0
  100. mai_agent/mai_naivigation_agent.py +526 -0
  101. mai_agent/prompt.py +148 -0
  102. mai_agent/unified_memory.py +67 -0
  103. mai_agent/utils.py +73 -0
  104. AutoGLM_GUI/api/dual_model.py +0 -311
  105. AutoGLM_GUI/dual_model/__init__.py +0 -53
  106. AutoGLM_GUI/dual_model/decision_model.py +0 -664
  107. AutoGLM_GUI/dual_model/dual_agent.py +0 -917
  108. AutoGLM_GUI/dual_model/protocols.py +0 -354
  109. AutoGLM_GUI/dual_model/vision_model.py +0 -442
  110. AutoGLM_GUI/mai_ui_adapter/agent_wrapper.py +0 -291
  111. AutoGLM_GUI/phone_agent_patches.py +0 -146
  112. AutoGLM_GUI/static/assets/chat-Iut2yhSw.js +0 -125
  113. AutoGLM_GUI/static/assets/dialog-BfdcBs1x.js +0 -45
  114. AutoGLM_GUI/static/assets/index-5hCCwHA7.css +0 -1
  115. AutoGLM_GUI/static/assets/index-DHF1NZh0.js +0 -12
  116. AutoGLM_GUI/static/assets/workflows-xiplap-r.js +0 -1
  117. autoglm_gui-1.4.0.dist-info/RECORD +0 -100
  118. {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/WHEEL +0 -0
  119. {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/entry_points.txt +0 -0
  120. {autoglm_gui-1.4.0.dist-info → autoglm_gui-1.5.0.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/schemas.py CHANGED
@@ -2,65 +2,34 @@
2
2
 
3
3
  import re
4
4
 
5
- from pydantic import BaseModel, Field, field_validator
5
+ from pydantic import BaseModel, field_validator
6
6
 
7
7
 
8
- class APIModelConfig(BaseModel):
9
- base_url: str | None = None
10
- api_key: str | None = None
11
- model_name: str | None = None
12
- max_tokens: int = 3000
13
- temperature: float = 0.0
14
- top_p: float = 0.85
15
- frequency_penalty: float = 0.2
16
-
17
- @field_validator("base_url")
18
- @classmethod
19
- def validate_base_url(cls, v: str | None) -> str | None:
20
- """验证 base_url 格式."""
21
- if v is None:
22
- return v
23
- v = v.strip()
24
- if not v:
25
- return None
26
- # 检查是否是有效的 HTTP/HTTPS URL
27
- if not re.match(r"^https?://", v):
28
- raise ValueError("base_url must start with http:// or https://")
29
- return v
8
+ class InitRequest(BaseModel):
9
+ device_id: str # Device ID (required)
30
10
 
11
+ # Agent configuration (factory pattern)
12
+ agent_type: str = "glm" # Agent type to use (e.g., "glm", "mai")
13
+ agent_config_params: dict | None = None # Agent-specific configuration parameters
31
14
 
32
- class APIAgentConfig(BaseModel):
33
- max_steps: int = 100
34
- device_id: str | None = None
35
- lang: str = "cn"
36
- system_prompt: str | None = None
37
- verbose: bool = True
38
-
39
- @field_validator("max_steps")
40
- @classmethod
41
- def validate_max_steps(cls, v: int) -> int:
42
- """验证 max_steps 范围."""
43
- if v <= 0:
44
- raise ValueError("max_steps must be positive")
45
- if v > 1000:
46
- raise ValueError("max_steps must be <= 1000")
47
- return v
15
+ # Hot-reload support
16
+ force: bool = False # Force re-initialization even if agent already exists
48
17
 
49
- @field_validator("lang")
18
+ @field_validator("agent_type")
50
19
  @classmethod
51
- def validate_lang(cls, v: str) -> str:
52
- """验证 lang 有效性."""
53
- allowed_langs = ["cn", "en"]
54
- if v not in allowed_langs:
55
- raise ValueError(f"lang must be one of {allowed_langs}")
20
+ def validate_agent_type(cls, v: str) -> str:
21
+ """验证 agent_type 有效性."""
22
+ # Don't import at module level to avoid circular imports
23
+ from AutoGLM_GUI.agents import is_agent_type_registered
24
+
25
+ if not is_agent_type_registered(v):
26
+ raise ValueError(
27
+ f"Unknown agent_type: '{v}'. "
28
+ f"Please register the agent type first or use a known type."
29
+ )
56
30
  return v
57
31
 
58
32
 
59
- class InitRequest(BaseModel):
60
- model: APIModelConfig | None = Field(default=None, alias="model_config")
61
- agent: APIAgentConfig | None = Field(default=None, alias="agent_config")
62
-
63
-
64
33
  class ChatRequest(BaseModel):
65
34
  message: str
66
35
  device_id: str # 设备 ID(必填)
@@ -320,11 +289,17 @@ class ConfigResponse(BaseModel):
320
289
  api_key: str # 返回实际值(明文)
321
290
  source: str # "CLI arguments" | "environment variables" | "config file (...)" | "default"
322
291
 
323
- # 双模型配置
324
- dual_model_enabled: bool = False
325
- decision_base_url: str = ""
326
- decision_model_name: str = ""
327
- decision_api_key: str = ""
292
+ # Agent 类型配置
293
+ agent_type: str = "glm" # Agent type (e.g., "glm", "mai")
294
+ agent_config_params: dict | None = None # Agent-specific configuration
295
+
296
+ # Agent 执行配置
297
+ default_max_steps: int = 100 # 单次任务最大执行步数
298
+
299
+ # 决策模型配置(用于分层代理)
300
+ decision_base_url: str | None = None
301
+ decision_model_name: str | None = None
302
+ decision_api_key: str | None = None
328
303
 
329
304
  conflicts: list[dict] | None = None # 配置冲突信息(可选)
330
305
 
@@ -336,12 +311,30 @@ class ConfigSaveRequest(BaseModel):
336
311
  model_name: str = "autoglm-phone-9b"
337
312
  api_key: str | None = None
338
313
 
339
- # 双模型配置
340
- dual_model_enabled: bool | None = None
314
+ # Agent 类型配置
315
+ agent_type: str = "glm" # Agent type to use (e.g., "glm", "mai")
316
+ agent_config_params: dict | None = None # Agent-specific configuration parameters
317
+
318
+ # Agent 执行配置
319
+ default_max_steps: int | None = None # 单次任务最大执行步数
320
+
321
+ # 决策模型配置(用于分层代理)
341
322
  decision_base_url: str | None = None
342
323
  decision_model_name: str | None = None
343
324
  decision_api_key: str | None = None
344
325
 
326
+ @field_validator("default_max_steps")
327
+ @classmethod
328
+ def validate_default_max_steps(cls, v: int | None) -> int | None:
329
+ """验证 default_max_steps 范围."""
330
+ if v is None:
331
+ return v
332
+ if v <= 0:
333
+ raise ValueError("default_max_steps must be positive")
334
+ if v > 1000:
335
+ raise ValueError("default_max_steps must be <= 1000")
336
+ return v
337
+
345
338
  @field_validator("base_url")
346
339
  @classmethod
347
340
  def validate_base_url(cls, v: str) -> str:
@@ -365,12 +358,21 @@ class ConfigSaveRequest(BaseModel):
365
358
  @classmethod
366
359
  def validate_decision_base_url(cls, v: str | None) -> str | None:
367
360
  """验证 decision_base_url 格式."""
368
- if v is None or not v.strip():
369
- return None
370
- v = v.strip()
371
- if not re.match(r"^https?://", v):
372
- raise ValueError("decision_base_url must start with http:// or https://")
373
- return v
361
+ if v is not None and v.strip():
362
+ if not re.match(r"^https?://", v):
363
+ raise ValueError(
364
+ "decision_base_url must start with http:// or https://"
365
+ )
366
+ return v.rstrip("/")
367
+ return None
368
+
369
+ @field_validator("decision_model_name")
370
+ @classmethod
371
+ def validate_decision_model_name(cls, v: str | None) -> str | None:
372
+ """验证 decision_model_name 非空."""
373
+ if v is not None and v.strip():
374
+ return v.strip()
375
+ return None
374
376
 
375
377
 
376
378
  class WiFiConnectRequest(BaseModel):
@@ -595,3 +597,210 @@ class WorkflowListResponse(BaseModel):
595
597
  """Workflow 列表响应."""
596
598
 
597
599
  workflows: list[WorkflowResponse]
600
+
601
+
602
+ class RemoteDeviceInfo(BaseModel):
603
+ """远程设备信息."""
604
+
605
+ device_id: str
606
+ model: str
607
+ platform: str
608
+ status: str
609
+
610
+
611
+ class RemoteDeviceDiscoverRequest(BaseModel):
612
+ """远程设备发现请求."""
613
+
614
+ base_url: str
615
+ timeout: int = 5
616
+
617
+ @field_validator("base_url")
618
+ @classmethod
619
+ def validate_base_url(cls, v: str) -> str:
620
+ v = v.strip().rstrip("/")
621
+ if not v.startswith(("http://", "https://")):
622
+ raise ValueError("base_url must start with http:// or https://")
623
+ return v
624
+
625
+ @field_validator("timeout")
626
+ @classmethod
627
+ def validate_timeout(cls, v: int) -> int:
628
+ if v <= 0:
629
+ raise ValueError("timeout must be positive")
630
+ if v > 30:
631
+ raise ValueError("timeout must be <= 30 seconds")
632
+ return v
633
+
634
+
635
+ class RemoteDeviceDiscoverResponse(BaseModel):
636
+ """远程设备发现响应."""
637
+
638
+ success: bool
639
+ devices: list[RemoteDeviceInfo]
640
+ message: str
641
+ error: str | None = None
642
+
643
+
644
+ class RemoteDeviceAddRequest(BaseModel):
645
+ """添加远程设备请求."""
646
+
647
+ base_url: str
648
+ device_id: str
649
+
650
+ @field_validator("base_url")
651
+ @classmethod
652
+ def validate_base_url(cls, v: str) -> str:
653
+ v = v.strip().rstrip("/")
654
+ if not v.startswith(("http://", "https://")):
655
+ raise ValueError("base_url must start with http:// or https://")
656
+ return v
657
+
658
+ @field_validator("device_id")
659
+ @classmethod
660
+ def validate_device_id(cls, v: str) -> str:
661
+ v = v.strip()
662
+ if not v:
663
+ raise ValueError("device_id cannot be empty")
664
+ if len(v) > 100:
665
+ raise ValueError("device_id too long (max 100 characters)")
666
+ return v
667
+
668
+
669
+ class RemoteDeviceAddResponse(BaseModel):
670
+ """添加远程设备响应."""
671
+
672
+ success: bool
673
+ message: str
674
+ serial: str | None = None
675
+ error: str | None = None
676
+
677
+
678
+ class RemoteDeviceRemoveRequest(BaseModel):
679
+ """移除远程设备请求."""
680
+
681
+ serial: str
682
+
683
+
684
+ class RemoteDeviceRemoveResponse(BaseModel):
685
+ """移除远程设备响应."""
686
+
687
+ success: bool
688
+ message: str
689
+ error: str | None = None
690
+
691
+
692
+ class ReinitAllAgentsResponse(BaseModel):
693
+ """批量重新初始化 agent 响应."""
694
+
695
+ success: bool
696
+ total: int
697
+ succeeded: list[str]
698
+ failed: dict[str, str]
699
+ message: str
700
+
701
+
702
+ # History Models
703
+
704
+
705
+ class HistoryRecordResponse(BaseModel):
706
+ """历史记录条目响应."""
707
+
708
+ id: str
709
+ task_text: str
710
+ final_message: str
711
+ success: bool
712
+ steps: int
713
+ start_time: str
714
+ end_time: str | None
715
+ duration_ms: int
716
+ source: str
717
+ source_detail: str
718
+ error_message: str | None
719
+
720
+
721
+ class HistoryListResponse(BaseModel):
722
+ """历史记录列表响应."""
723
+
724
+ records: list[HistoryRecordResponse]
725
+ total: int
726
+ limit: int
727
+ offset: int
728
+
729
+
730
+ # Scheduled Task Models
731
+
732
+
733
+ class ScheduledTaskCreate(BaseModel):
734
+ """创建定时任务请求."""
735
+
736
+ name: str
737
+ workflow_uuid: str
738
+ device_serialno: str
739
+ cron_expression: str
740
+ enabled: bool = True
741
+
742
+ @field_validator("name")
743
+ @classmethod
744
+ def validate_name(cls, v: str) -> str:
745
+ if not v or not v.strip():
746
+ raise ValueError("name cannot be empty")
747
+ return v.strip()
748
+
749
+ @field_validator("cron_expression")
750
+ @classmethod
751
+ def validate_cron(cls, v: str) -> str:
752
+ if not v or not v.strip():
753
+ raise ValueError("cron_expression cannot be empty")
754
+ parts = v.strip().split()
755
+ if len(parts) != 5:
756
+ raise ValueError(
757
+ "cron_expression must have 5 fields (minute hour day month weekday)"
758
+ )
759
+ return v.strip()
760
+
761
+
762
+ class ScheduledTaskUpdate(BaseModel):
763
+ """更新定时任务请求."""
764
+
765
+ name: str | None = None
766
+ workflow_uuid: str | None = None
767
+ device_serialno: str | None = None
768
+ cron_expression: str | None = None
769
+ enabled: bool | None = None
770
+
771
+ @field_validator("cron_expression")
772
+ @classmethod
773
+ def validate_cron(cls, v: str | None) -> str | None:
774
+ if v is None:
775
+ return v
776
+ if not v.strip():
777
+ raise ValueError("cron_expression cannot be empty")
778
+ parts = v.strip().split()
779
+ if len(parts) != 5:
780
+ raise ValueError(
781
+ "cron_expression must have 5 fields (minute hour day month weekday)"
782
+ )
783
+ return v.strip()
784
+
785
+
786
+ class ScheduledTaskResponse(BaseModel):
787
+ """定时任务响应."""
788
+
789
+ id: str
790
+ name: str
791
+ workflow_uuid: str
792
+ device_serialno: str
793
+ cron_expression: str
794
+ enabled: bool
795
+ created_at: str
796
+ updated_at: str
797
+ last_run_time: str | None
798
+ last_run_success: bool | None
799
+ last_run_message: str | None
800
+ next_run_time: str | None = None
801
+
802
+
803
+ class ScheduledTaskListResponse(BaseModel):
804
+ """定时任务列表响应."""
805
+
806
+ tasks: list[ScheduledTaskResponse]
@@ -5,9 +5,10 @@ import os
5
5
  import socket
6
6
  import subprocess
7
7
  import sys
8
+ import time
8
9
  from dataclasses import dataclass
9
10
  from pathlib import Path
10
- from typing import Any
11
+ from asyncio.subprocess import Process as AsyncProcess
11
12
 
12
13
  from AutoGLM_GUI.adb_plus import check_device_available
13
14
  from AutoGLM_GUI.logger import logger
@@ -23,6 +24,74 @@ from AutoGLM_GUI.scrcpy_protocol import (
23
24
  )
24
25
 
25
26
 
27
+ async def is_port_available(port: int, host: str = "127.0.0.1") -> bool:
28
+ """Test if TCP port is available for binding.
29
+
30
+ Args:
31
+ port: TCP port number
32
+ host: Host address to test
33
+
34
+ Returns:
35
+ True if port can be bound (available), False otherwise
36
+ """
37
+ sock = None
38
+ try:
39
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
40
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
41
+ sock.setblocking(False)
42
+ sock.bind((host, port))
43
+ logger.debug(f"Port {port} is available for binding")
44
+ return True
45
+ except OSError as e:
46
+ # Handle cross-platform errno for "Address already in use"
47
+ # macOS: 48, Linux: 98, Windows: 10048
48
+ logger.debug(f"Port {port} is occupied: {e}")
49
+ return False
50
+ finally:
51
+ if sock:
52
+ sock.close()
53
+
54
+
55
+ async def wait_for_port_release(
56
+ port: int,
57
+ timeout: float = 5.0,
58
+ poll_interval: float = 0.2,
59
+ host: str = "127.0.0.1",
60
+ ) -> bool:
61
+ """Wait for TCP port to become available with polling.
62
+
63
+ Args:
64
+ port: TCP port to wait for
65
+ timeout: Maximum wait time in seconds (default: 5.0)
66
+ poll_interval: Check interval in seconds (default: 0.2)
67
+ host: Host address
68
+
69
+ Returns:
70
+ True if port became available, False if timeout
71
+ """
72
+ start_time = time.time()
73
+ attempt = 0
74
+
75
+ while time.time() - start_time < timeout:
76
+ attempt += 1
77
+ if await is_port_available(port, host):
78
+ elapsed = time.time() - start_time
79
+ logger.info(
80
+ f"Port {port} became available after {elapsed:.2f}s ({attempt} checks)"
81
+ )
82
+ return True
83
+
84
+ # Log progress every second for debugging
85
+ if attempt % 5 == 0: # Every 1 second (5 * 0.2s)
86
+ elapsed = time.time() - start_time
87
+ logger.debug(f"Still waiting for port {port}... ({elapsed:.1f}s elapsed)")
88
+
89
+ await asyncio.sleep(poll_interval)
90
+
91
+ logger.warning(f"Port {port} did not release within {timeout}s timeout")
92
+ return False
93
+
94
+
26
95
  @dataclass
27
96
  class ScrcpyServerOptions:
28
97
  max_size: int
@@ -69,7 +138,7 @@ class ScrcpyStreamer:
69
138
  self.idr_interval_s = idr_interval_s
70
139
  self.stream_options = stream_options or ScrcpyVideoStreamOptions()
71
140
 
72
- self.scrcpy_process: Any | None = None
141
+ self.scrcpy_process: subprocess.Popen[bytes] | AsyncProcess | None = None
73
142
  self.tcp_socket: socket.socket | None = None
74
143
  self.forward_cleanup_needed = False
75
144
 
@@ -83,8 +152,9 @@ class ScrcpyStreamer:
83
152
  def _find_scrcpy_server(self) -> str:
84
153
  """Find scrcpy-server binary path."""
85
154
  # Priority 1: PyInstaller bundled path (for packaged executable)
86
- if getattr(sys, "_MEIPASS", None):
87
- bundled_server = Path(sys._MEIPASS) / "scrcpy-server-v3.3.3"
155
+ meipass = getattr(sys, "_MEIPASS", None)
156
+ if meipass:
157
+ bundled_server = Path(meipass) / "scrcpy-server-v3.3.3"
88
158
  if bundled_server.exists():
89
159
  logger.info(f"Using bundled scrcpy-server: {bundled_server}")
90
160
  return str(bundled_server)
@@ -158,29 +228,44 @@ class ScrcpyStreamer:
158
228
  raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
159
229
 
160
230
  async def _cleanup_existing_server(self) -> None:
161
- """Kill existing scrcpy server processes on device."""
231
+ """Kill existing scrcpy server processes and wait for port release."""
162
232
  cmd_base = ["adb"]
163
233
  if self.device_id:
164
234
  cmd_base.extend(["-s", self.device_id])
165
235
 
166
236
  # Method 1: Try pkill
237
+ logger.debug("Killing scrcpy processes via pkill...")
167
238
  cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
168
239
  await run_cmd_silently(cmd)
169
240
 
170
241
  # Method 2: Find and kill by PID (more reliable)
242
+ logger.debug("Killing scrcpy processes via PID...")
171
243
  cmd = cmd_base + [
172
244
  "shell",
173
245
  "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9",
174
246
  ]
175
247
  await run_cmd_silently(cmd)
176
248
 
177
- # Method 3: Remove port forward if exists
249
+ # Method 3: Remove port forward
250
+ logger.debug(f"Removing ADB port forward on port {self.port}...")
178
251
  cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
179
252
  await run_cmd_silently(cmd_remove_forward)
180
253
 
181
- # Wait for resources to be released
182
- logger.debug("Waiting for cleanup to complete...")
183
- await asyncio.sleep(2)
254
+ # Wait for port to be truly available (instead of fixed sleep)
255
+ logger.info(f"Waiting for port {self.port} to be released...")
256
+ port_released = await wait_for_port_release(
257
+ self.port,
258
+ timeout=5.0, # Max 5 seconds (vs old fixed 2s)
259
+ poll_interval=0.2, # Check every 200ms
260
+ )
261
+
262
+ if not port_released:
263
+ logger.warning(
264
+ f"Port {self.port} still occupied after cleanup. "
265
+ "Will attempt to start anyway (may fail)."
266
+ )
267
+ else:
268
+ logger.info(f"Port {self.port} successfully released and ready")
184
269
 
185
270
  async def _push_server(self) -> None:
186
271
  """Push scrcpy-server to device."""
@@ -220,9 +305,9 @@ class ScrcpyStreamer:
220
305
  )
221
306
 
222
307
  async def _start_server(self) -> None:
223
- """Start scrcpy server on device with retry on address conflict."""
308
+ """Start scrcpy server on device with intelligent retry."""
224
309
  max_retries = 3
225
- retry_delay = 2
310
+ retry_delay = 1.0 # Reduced from 2s (cleanup handles waiting now)
226
311
 
227
312
  options = self._build_server_options()
228
313
 
@@ -262,53 +347,89 @@ class ScrcpyStreamer:
262
347
 
263
348
  # Check if process is still running
264
349
  error_msg = None
265
- if is_windows():
266
- if self.scrcpy_process.poll() is not None:
267
- stdout, stderr = self.scrcpy_process.communicate()
268
- error_msg = stderr.decode() if stderr else stdout.decode()
269
- else:
270
- if self.scrcpy_process.returncode is not None:
271
- stdout, stderr = await self.scrcpy_process.communicate()
272
- error_msg = stderr.decode() if stderr else stdout.decode()
350
+ proc = self.scrcpy_process
351
+ if proc is not None:
352
+ if is_windows():
353
+ if proc.poll() is not None: # type: ignore[union-attr]
354
+ stdout, stderr = proc.communicate() # type: ignore[union-attr]
355
+ error_msg = stderr.decode() if stderr else stdout.decode()
356
+ else:
357
+ if proc.returncode is not None: # type: ignore[union-attr]
358
+ stdout, stderr = await proc.communicate() # type: ignore[union-attr]
359
+ error_msg = stderr.decode() if stderr else stdout.decode()
273
360
 
274
361
  if error_msg is not None:
362
+ # Detailed error classification
275
363
  if "Address already in use" in error_msg:
364
+ logger.error(
365
+ f"Port {self.port} conflict detected (attempt {attempt + 1}/{max_retries}). "
366
+ f"Error: {error_msg[:200]}"
367
+ )
276
368
  if attempt < max_retries - 1:
277
369
  logger.warning(
278
- f"Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..."
370
+ f"Retrying with aggressive cleanup in {retry_delay}s..."
279
371
  )
280
372
  await self._cleanup_existing_server()
281
373
  await asyncio.sleep(retry_delay)
282
374
  continue
375
+ # Specific error for port conflicts
283
376
  raise RuntimeError(
284
- f"scrcpy server failed after {max_retries} attempts: {error_msg}"
377
+ f"Port {self.port} persistently occupied after {max_retries} attempts. "
378
+ "Please check if another scrcpy instance is running."
285
379
  )
286
- raise RuntimeError(f"scrcpy server exited immediately: {error_msg}")
380
+ else:
381
+ # Non-port errors fail immediately (no retry)
382
+ logger.error(f"Scrcpy server startup failed: {error_msg[:200]}")
383
+ raise RuntimeError(f"Scrcpy server failed to start: {error_msg}")
287
384
 
385
+ logger.info("Scrcpy server started successfully")
288
386
  return
289
387
 
290
388
  raise RuntimeError("Failed to start scrcpy server after maximum retries")
291
389
 
292
390
  async def _connect_socket(self) -> None:
293
391
  """Connect to scrcpy TCP socket."""
294
- self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
295
- self.tcp_socket.settimeout(5)
392
+ # Retry connection with exponential backoff (max ~6 seconds total)
393
+ max_attempts = 10
394
+ retry_delay = 0.3
296
395
 
297
- try:
298
- self.tcp_socket.setsockopt(
299
- socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024
300
- )
301
- logger.debug("Set socket receive buffer to 2MB")
302
- except OSError as e:
303
- logger.warning(f"Failed to set socket buffer size: {e}")
396
+ for attempt in range(max_attempts):
397
+ # Create a fresh socket for each attempt to avoid "Invalid argument" error
398
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
399
+ sock.settimeout(5)
304
400
 
305
- for _ in range(5):
306
401
  try:
307
- self.tcp_socket.connect(("localhost", self.port))
308
- self.tcp_socket.settimeout(None)
402
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024)
403
+ except OSError as e:
404
+ logger.debug(f"Failed to set socket buffer size: {e}")
405
+
406
+ try:
407
+ sock.connect(("localhost", self.port))
408
+ sock.settimeout(None)
409
+ self.tcp_socket = sock # Only assign on success
410
+ logger.debug(f"Connected to scrcpy server on attempt {attempt + 1}")
309
411
  return
310
- except (ConnectionRefusedError, OSError):
311
- await asyncio.sleep(0.5)
412
+ except (ConnectionRefusedError, OSError) as e:
413
+ # Close the failed socket
414
+ try:
415
+ sock.close()
416
+ except Exception:
417
+ pass
418
+
419
+ if attempt < max_attempts - 1:
420
+ logger.debug(
421
+ f"Connection attempt {attempt + 1}/{max_attempts} failed: {e}. "
422
+ f"Retrying in {retry_delay}s..."
423
+ )
424
+ await asyncio.sleep(retry_delay)
425
+ # Gradually increase delay for later attempts
426
+ if attempt >= 3:
427
+ retry_delay = 0.5
428
+ else:
429
+ logger.error(
430
+ f"Failed to connect after {max_attempts} attempts. "
431
+ f"Last error: {e}"
432
+ )
312
433
 
313
434
  raise ConnectionError("Failed to connect to scrcpy server")
314
435
 
@@ -431,7 +552,8 @@ class ScrcpyStreamer:
431
552
  if self.scrcpy_process:
432
553
  try:
433
554
  self.scrcpy_process.terminate()
434
- self.scrcpy_process.wait(timeout=2)
555
+ if isinstance(self.scrcpy_process, subprocess.Popen):
556
+ self.scrcpy_process.wait(timeout=2)
435
557
  except Exception:
436
558
  try:
437
559
  self.scrcpy_process.kill()
AutoGLM_GUI/server.py CHANGED
@@ -5,6 +5,8 @@ from socketio import ASGIApp
5
5
  from AutoGLM_GUI.api import app as fastapi_app
6
6
  from AutoGLM_GUI.socketio_server import sio
7
7
 
8
- app = ASGIApp(sio, other_asgi_app=fastapi_app)
8
+ app = ASGIApp(
9
+ other_asgi_app=fastapi_app, socketio_server=sio, socketio_path="/socket.io"
10
+ )
9
11
 
10
12
  __all__ = ["app"]