autoglm-gui 1.4.1__py3-none-any.whl → 1.5.1__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 (135) hide show
  1. AutoGLM_GUI/__init__.py +11 -0
  2. AutoGLM_GUI/__main__.py +26 -4
  3. AutoGLM_GUI/actions/__init__.py +6 -0
  4. phone_agent/actions/handler_ios.py → AutoGLM_GUI/actions/handler.py +30 -112
  5. AutoGLM_GUI/actions/types.py +15 -0
  6. {phone_agent → AutoGLM_GUI}/adb/__init__.py +25 -23
  7. {phone_agent → AutoGLM_GUI}/adb/connection.py +5 -40
  8. {phone_agent → AutoGLM_GUI}/adb/device.py +12 -94
  9. {phone_agent → AutoGLM_GUI}/adb/input.py +6 -47
  10. AutoGLM_GUI/adb/screenshot.py +11 -0
  11. {phone_agent/config → AutoGLM_GUI/adb}/timing.py +1 -1
  12. AutoGLM_GUI/adb_plus/keyboard_installer.py +4 -2
  13. AutoGLM_GUI/adb_plus/screenshot.py +22 -1
  14. AutoGLM_GUI/adb_plus/serial.py +38 -20
  15. AutoGLM_GUI/adb_plus/touch.py +4 -9
  16. AutoGLM_GUI/agents/__init__.py +43 -12
  17. AutoGLM_GUI/agents/events.py +19 -0
  18. AutoGLM_GUI/agents/factory.py +31 -38
  19. AutoGLM_GUI/agents/glm/__init__.py +7 -0
  20. AutoGLM_GUI/agents/glm/agent.py +297 -0
  21. AutoGLM_GUI/agents/glm/message_builder.py +81 -0
  22. AutoGLM_GUI/agents/glm/parser.py +110 -0
  23. {phone_agent/config → AutoGLM_GUI/agents/glm}/prompts_en.py +7 -9
  24. {phone_agent/config → AutoGLM_GUI/agents/glm}/prompts_zh.py +18 -25
  25. AutoGLM_GUI/agents/mai/__init__.py +28 -0
  26. AutoGLM_GUI/agents/mai/agent.py +408 -0
  27. AutoGLM_GUI/agents/mai/parser.py +254 -0
  28. AutoGLM_GUI/agents/mai/prompts.py +103 -0
  29. AutoGLM_GUI/agents/mai/traj_memory.py +91 -0
  30. AutoGLM_GUI/agents/protocols.py +12 -8
  31. AutoGLM_GUI/agents/stream_runner.py +193 -0
  32. AutoGLM_GUI/api/__init__.py +40 -21
  33. AutoGLM_GUI/api/agents.py +181 -239
  34. AutoGLM_GUI/api/control.py +9 -6
  35. AutoGLM_GUI/api/devices.py +102 -12
  36. AutoGLM_GUI/api/history.py +104 -0
  37. AutoGLM_GUI/api/layered_agent.py +67 -15
  38. AutoGLM_GUI/api/media.py +64 -1
  39. AutoGLM_GUI/api/scheduled_tasks.py +98 -0
  40. AutoGLM_GUI/config.py +81 -0
  41. AutoGLM_GUI/config_manager.py +68 -51
  42. AutoGLM_GUI/device_manager.py +248 -29
  43. AutoGLM_GUI/device_protocol.py +1 -1
  44. AutoGLM_GUI/devices/adb_device.py +5 -10
  45. AutoGLM_GUI/devices/mock_device.py +4 -2
  46. AutoGLM_GUI/devices/remote_device.py +8 -3
  47. AutoGLM_GUI/history_manager.py +164 -0
  48. AutoGLM_GUI/model/__init__.py +5 -0
  49. AutoGLM_GUI/model/message_builder.py +69 -0
  50. AutoGLM_GUI/model/types.py +24 -0
  51. AutoGLM_GUI/models/__init__.py +10 -0
  52. AutoGLM_GUI/models/history.py +140 -0
  53. AutoGLM_GUI/models/scheduled_task.py +71 -0
  54. AutoGLM_GUI/parsers/__init__.py +22 -0
  55. AutoGLM_GUI/parsers/base.py +50 -0
  56. AutoGLM_GUI/parsers/phone_parser.py +58 -0
  57. AutoGLM_GUI/phone_agent_manager.py +62 -396
  58. AutoGLM_GUI/platform_utils.py +26 -0
  59. AutoGLM_GUI/prompt_config.py +15 -0
  60. AutoGLM_GUI/prompts/__init__.py +32 -0
  61. AutoGLM_GUI/scheduler_manager.py +350 -0
  62. AutoGLM_GUI/schemas.py +246 -72
  63. AutoGLM_GUI/scrcpy_stream.py +142 -24
  64. AutoGLM_GUI/socketio_server.py +100 -27
  65. AutoGLM_GUI/static/assets/{about-_XNhzQZX.js → about-CfwX1Cmc.js} +1 -1
  66. AutoGLM_GUI/static/assets/alert-dialog-CtGlN2IJ.js +1 -0
  67. AutoGLM_GUI/static/assets/chat-BYa-foUI.js +129 -0
  68. AutoGLM_GUI/static/assets/circle-alert-t08bEMPO.js +1 -0
  69. AutoGLM_GUI/static/assets/dialog-FNwZJFwk.js +45 -0
  70. AutoGLM_GUI/static/assets/eye-D0UPWCWC.js +1 -0
  71. AutoGLM_GUI/static/assets/history-CRo95B7i.js +1 -0
  72. AutoGLM_GUI/static/assets/{index-Cy8TmmHV.js → index-BaLMSqd3.js} +1 -1
  73. AutoGLM_GUI/static/assets/index-CTHbFvKl.js +11 -0
  74. AutoGLM_GUI/static/assets/index-CV7jGxGm.css +1 -0
  75. AutoGLM_GUI/static/assets/label-DJFevVmr.js +1 -0
  76. AutoGLM_GUI/static/assets/logs-RW09DyYY.js +1 -0
  77. AutoGLM_GUI/static/assets/popover--JTJrE5v.js +1 -0
  78. AutoGLM_GUI/static/assets/scheduled-tasks-DTRKsQXF.js +1 -0
  79. AutoGLM_GUI/static/assets/square-pen-CPK_K680.js +1 -0
  80. AutoGLM_GUI/static/assets/textarea-PRmVnWq5.js +1 -0
  81. AutoGLM_GUI/static/assets/workflows-CdcsAoaT.js +1 -0
  82. AutoGLM_GUI/static/index.html +2 -2
  83. AutoGLM_GUI/types.py +17 -0
  84. {autoglm_gui-1.4.1.dist-info → autoglm_gui-1.5.1.dist-info}/METADATA +179 -130
  85. autoglm_gui-1.5.1.dist-info/RECORD +118 -0
  86. AutoGLM_GUI/agents/mai_adapter.py +0 -627
  87. AutoGLM_GUI/api/dual_model.py +0 -317
  88. AutoGLM_GUI/device_adapter.py +0 -263
  89. AutoGLM_GUI/dual_model/__init__.py +0 -53
  90. AutoGLM_GUI/dual_model/decision_model.py +0 -664
  91. AutoGLM_GUI/dual_model/dual_agent.py +0 -917
  92. AutoGLM_GUI/dual_model/protocols.py +0 -354
  93. AutoGLM_GUI/dual_model/vision_model.py +0 -442
  94. AutoGLM_GUI/mai_ui_adapter/agent_wrapper.py +0 -291
  95. AutoGLM_GUI/phone_agent_patches.py +0 -147
  96. AutoGLM_GUI/static/assets/chat-DwJpiAWf.js +0 -126
  97. AutoGLM_GUI/static/assets/dialog-B3uW4T8V.js +0 -45
  98. AutoGLM_GUI/static/assets/index-Cpv2gSF1.css +0 -1
  99. AutoGLM_GUI/static/assets/index-UYYauTly.js +0 -12
  100. AutoGLM_GUI/static/assets/workflows-Du_de-dt.js +0 -1
  101. autoglm_gui-1.4.1.dist-info/RECORD +0 -117
  102. mai_agent/base.py +0 -137
  103. mai_agent/mai_grounding_agent.py +0 -263
  104. mai_agent/mai_naivigation_agent.py +0 -526
  105. mai_agent/prompt.py +0 -148
  106. mai_agent/unified_memory.py +0 -67
  107. mai_agent/utils.py +0 -73
  108. phone_agent/__init__.py +0 -12
  109. phone_agent/actions/__init__.py +0 -5
  110. phone_agent/actions/handler.py +0 -400
  111. phone_agent/adb/screenshot.py +0 -108
  112. phone_agent/agent.py +0 -253
  113. phone_agent/agent_ios.py +0 -277
  114. phone_agent/config/__init__.py +0 -53
  115. phone_agent/config/apps_harmonyos.py +0 -256
  116. phone_agent/config/apps_ios.py +0 -339
  117. phone_agent/config/prompts.py +0 -80
  118. phone_agent/device_factory.py +0 -166
  119. phone_agent/hdc/__init__.py +0 -53
  120. phone_agent/hdc/connection.py +0 -384
  121. phone_agent/hdc/device.py +0 -269
  122. phone_agent/hdc/input.py +0 -145
  123. phone_agent/hdc/screenshot.py +0 -127
  124. phone_agent/model/__init__.py +0 -5
  125. phone_agent/model/client.py +0 -290
  126. phone_agent/xctest/__init__.py +0 -47
  127. phone_agent/xctest/connection.py +0 -379
  128. phone_agent/xctest/device.py +0 -472
  129. phone_agent/xctest/input.py +0 -311
  130. phone_agent/xctest/screenshot.py +0 -226
  131. {phone_agent/config → AutoGLM_GUI/adb}/apps.py +0 -0
  132. {phone_agent/config → AutoGLM_GUI}/i18n.py +0 -0
  133. {autoglm_gui-1.4.1.dist-info → autoglm_gui-1.5.1.dist-info}/WHEEL +0 -0
  134. {autoglm_gui-1.4.1.dist-info → autoglm_gui-1.5.1.dist-info}/entry_points.txt +0 -0
  135. {autoglm_gui-1.4.1.dist-info → autoglm_gui-1.5.1.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/schemas.py CHANGED
@@ -2,63 +2,11 @@
2
2
 
3
3
  import re
4
4
 
5
- from pydantic import BaseModel, Field, field_validator
6
-
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
30
-
31
-
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
48
-
49
- @field_validator("lang")
50
- @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}")
56
- return v
5
+ from pydantic import BaseModel, field_validator
57
6
 
58
7
 
59
8
  class InitRequest(BaseModel):
60
- model: APIModelConfig | None = Field(default=None, alias="model_config")
61
- agent: APIAgentConfig | None = Field(default=None, alias="agent_config")
9
+ device_id: str # Device ID (required)
62
10
 
63
11
  # Agent configuration (factory pattern)
64
12
  agent_type: str = "glm" # Agent type to use (e.g., "glm", "mai")
@@ -341,12 +289,6 @@ class ConfigResponse(BaseModel):
341
289
  api_key: str # 返回实际值(明文)
342
290
  source: str # "CLI arguments" | "environment variables" | "config file (...)" | "default"
343
291
 
344
- # 双模型配置
345
- dual_model_enabled: bool = False
346
- decision_base_url: str = ""
347
- decision_model_name: str = ""
348
- decision_api_key: str = ""
349
-
350
292
  # Agent 类型配置
351
293
  agent_type: str = "glm" # Agent type (e.g., "glm", "mai")
352
294
  agent_config_params: dict | None = None # Agent-specific configuration
@@ -354,6 +296,11 @@ class ConfigResponse(BaseModel):
354
296
  # Agent 执行配置
355
297
  default_max_steps: int = 100 # 单次任务最大执行步数
356
298
 
299
+ # 决策模型配置(用于分层代理)
300
+ decision_base_url: str | None = None
301
+ decision_model_name: str | None = None
302
+ decision_api_key: str | None = None
303
+
357
304
  conflicts: list[dict] | None = None # 配置冲突信息(可选)
358
305
 
359
306
 
@@ -364,12 +311,6 @@ class ConfigSaveRequest(BaseModel):
364
311
  model_name: str = "autoglm-phone-9b"
365
312
  api_key: str | None = None
366
313
 
367
- # 双模型配置
368
- dual_model_enabled: bool | None = None
369
- decision_base_url: str | None = None
370
- decision_model_name: str | None = None
371
- decision_api_key: str | None = None
372
-
373
314
  # Agent 类型配置
374
315
  agent_type: str = "glm" # Agent type to use (e.g., "glm", "mai")
375
316
  agent_config_params: dict | None = None # Agent-specific configuration parameters
@@ -377,6 +318,11 @@ class ConfigSaveRequest(BaseModel):
377
318
  # Agent 执行配置
378
319
  default_max_steps: int | None = None # 单次任务最大执行步数
379
320
 
321
+ # 决策模型配置(用于分层代理)
322
+ decision_base_url: str | None = None
323
+ decision_model_name: str | None = None
324
+ decision_api_key: str | None = None
325
+
380
326
  @field_validator("default_max_steps")
381
327
  @classmethod
382
328
  def validate_default_max_steps(cls, v: int | None) -> int | None:
@@ -412,12 +358,21 @@ class ConfigSaveRequest(BaseModel):
412
358
  @classmethod
413
359
  def validate_decision_base_url(cls, v: str | None) -> str | None:
414
360
  """验证 decision_base_url 格式."""
415
- if v is None or not v.strip():
416
- return None
417
- v = v.strip()
418
- if not re.match(r"^https?://", v):
419
- raise ValueError("decision_base_url must start with http:// or https://")
420
- 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
421
376
 
422
377
 
423
378
  class WiFiConnectRequest(BaseModel):
@@ -642,3 +597,222 @@ class WorkflowListResponse(BaseModel):
642
597
  """Workflow 列表响应."""
643
598
 
644
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 MessageRecordResponse(BaseModel):
706
+ """对话消息响应."""
707
+
708
+ role: str # "user" | "assistant"
709
+ content: str
710
+ timestamp: str
711
+ thinking: str | None = None
712
+ action: dict | None = None
713
+ step: int | None = None
714
+
715
+
716
+ class HistoryRecordResponse(BaseModel):
717
+ """历史记录条目响应."""
718
+
719
+ id: str
720
+ task_text: str
721
+ final_message: str
722
+ success: bool
723
+ steps: int
724
+ start_time: str
725
+ end_time: str | None
726
+ duration_ms: int
727
+ source: str
728
+ source_detail: str
729
+ error_message: str | None
730
+ messages: list[MessageRecordResponse] = []
731
+
732
+
733
+ class HistoryListResponse(BaseModel):
734
+ """历史记录列表响应."""
735
+
736
+ records: list[HistoryRecordResponse]
737
+ total: int
738
+ limit: int
739
+ offset: int
740
+
741
+
742
+ # Scheduled Task Models
743
+
744
+
745
+ class ScheduledTaskCreate(BaseModel):
746
+ """创建定时任务请求."""
747
+
748
+ name: str
749
+ workflow_uuid: str
750
+ device_serialno: str
751
+ cron_expression: str
752
+ enabled: bool = True
753
+
754
+ @field_validator("name")
755
+ @classmethod
756
+ def validate_name(cls, v: str) -> str:
757
+ if not v or not v.strip():
758
+ raise ValueError("name cannot be empty")
759
+ return v.strip()
760
+
761
+ @field_validator("cron_expression")
762
+ @classmethod
763
+ def validate_cron(cls, v: str) -> str:
764
+ if not v or not v.strip():
765
+ raise ValueError("cron_expression cannot be empty")
766
+ parts = v.strip().split()
767
+ if len(parts) != 5:
768
+ raise ValueError(
769
+ "cron_expression must have 5 fields (minute hour day month weekday)"
770
+ )
771
+ return v.strip()
772
+
773
+
774
+ class ScheduledTaskUpdate(BaseModel):
775
+ """更新定时任务请求."""
776
+
777
+ name: str | None = None
778
+ workflow_uuid: str | None = None
779
+ device_serialno: str | None = None
780
+ cron_expression: str | None = None
781
+ enabled: bool | None = None
782
+
783
+ @field_validator("cron_expression")
784
+ @classmethod
785
+ def validate_cron(cls, v: str | None) -> str | None:
786
+ if v is None:
787
+ return v
788
+ if not v.strip():
789
+ raise ValueError("cron_expression cannot be empty")
790
+ parts = v.strip().split()
791
+ if len(parts) != 5:
792
+ raise ValueError(
793
+ "cron_expression must have 5 fields (minute hour day month weekday)"
794
+ )
795
+ return v.strip()
796
+
797
+
798
+ class ScheduledTaskResponse(BaseModel):
799
+ """定时任务响应."""
800
+
801
+ id: str
802
+ name: str
803
+ workflow_uuid: str
804
+ device_serialno: str
805
+ cron_expression: str
806
+ enabled: bool
807
+ created_at: str
808
+ updated_at: str
809
+ last_run_time: str | None
810
+ last_run_success: bool | None
811
+ last_run_message: str | None
812
+ next_run_time: str | None = None
813
+
814
+
815
+ class ScheduledTaskListResponse(BaseModel):
816
+ """定时任务列表响应."""
817
+
818
+ tasks: list[ScheduledTaskResponse]
@@ -5,6 +5,7 @@ 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
11
  from asyncio.subprocess import Process as AsyncProcess
@@ -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
@@ -159,29 +228,44 @@ class ScrcpyStreamer:
159
228
  raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
160
229
 
161
230
  async def _cleanup_existing_server(self) -> None:
162
- """Kill existing scrcpy server processes on device."""
231
+ """Kill existing scrcpy server processes and wait for port release."""
163
232
  cmd_base = ["adb"]
164
233
  if self.device_id:
165
234
  cmd_base.extend(["-s", self.device_id])
166
235
 
167
236
  # Method 1: Try pkill
237
+ logger.debug("Killing scrcpy processes via pkill...")
168
238
  cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
169
239
  await run_cmd_silently(cmd)
170
240
 
171
241
  # Method 2: Find and kill by PID (more reliable)
242
+ logger.debug("Killing scrcpy processes via PID...")
172
243
  cmd = cmd_base + [
173
244
  "shell",
174
245
  "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9",
175
246
  ]
176
247
  await run_cmd_silently(cmd)
177
248
 
178
- # 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}...")
179
251
  cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
180
252
  await run_cmd_silently(cmd_remove_forward)
181
253
 
182
- # Wait for resources to be released
183
- logger.debug("Waiting for cleanup to complete...")
184
- 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")
185
269
 
186
270
  async def _push_server(self) -> None:
187
271
  """Push scrcpy-server to device."""
@@ -221,9 +305,9 @@ class ScrcpyStreamer:
221
305
  )
222
306
 
223
307
  async def _start_server(self) -> None:
224
- """Start scrcpy server on device with retry on address conflict."""
308
+ """Start scrcpy server on device with intelligent retry."""
225
309
  max_retries = 3
226
- retry_delay = 2
310
+ retry_delay = 1.0 # Reduced from 2s (cleanup handles waiting now)
227
311
 
228
312
  options = self._build_server_options()
229
313
 
@@ -275,43 +359,77 @@ class ScrcpyStreamer:
275
359
  error_msg = stderr.decode() if stderr else stdout.decode()
276
360
 
277
361
  if error_msg is not None:
362
+ # Detailed error classification
278
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
+ )
279
368
  if attempt < max_retries - 1:
280
369
  logger.warning(
281
- f"Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..."
370
+ f"Retrying with aggressive cleanup in {retry_delay}s..."
282
371
  )
283
372
  await self._cleanup_existing_server()
284
373
  await asyncio.sleep(retry_delay)
285
374
  continue
375
+ # Specific error for port conflicts
286
376
  raise RuntimeError(
287
- 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."
288
379
  )
289
- 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}")
290
384
 
385
+ logger.info("Scrcpy server started successfully")
291
386
  return
292
387
 
293
388
  raise RuntimeError("Failed to start scrcpy server after maximum retries")
294
389
 
295
390
  async def _connect_socket(self) -> None:
296
391
  """Connect to scrcpy TCP socket."""
297
- self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
298
- self.tcp_socket.settimeout(5)
392
+ # Retry connection with exponential backoff (max ~6 seconds total)
393
+ max_attempts = 10
394
+ retry_delay = 0.3
299
395
 
300
- try:
301
- self.tcp_socket.setsockopt(
302
- socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024
303
- )
304
- logger.debug("Set socket receive buffer to 2MB")
305
- except OSError as e:
306
- 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)
400
+
401
+ try:
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}")
307
405
 
308
- for _ in range(5):
309
406
  try:
310
- self.tcp_socket.connect(("localhost", self.port))
311
- self.tcp_socket.settimeout(None)
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}")
312
411
  return
313
- except (ConnectionRefusedError, OSError):
314
- 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
+ )
315
433
 
316
434
  raise ConnectionError("Failed to connect to scrcpy server")
317
435