autoglm-gui 1.0.2__py3-none-any.whl → 1.2.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.
- AutoGLM_GUI/adb_plus/__init__.py +12 -1
- AutoGLM_GUI/adb_plus/mdns.py +192 -0
- AutoGLM_GUI/adb_plus/pair.py +60 -0
- AutoGLM_GUI/adb_plus/qr_pair.py +372 -0
- AutoGLM_GUI/adb_plus/serial.py +61 -2
- AutoGLM_GUI/adb_plus/version.py +81 -0
- AutoGLM_GUI/api/__init__.py +16 -1
- AutoGLM_GUI/api/agents.py +329 -94
- AutoGLM_GUI/api/devices.py +304 -100
- AutoGLM_GUI/api/workflows.py +70 -0
- AutoGLM_GUI/device_manager.py +760 -0
- AutoGLM_GUI/exceptions.py +18 -0
- AutoGLM_GUI/phone_agent_manager.py +549 -0
- AutoGLM_GUI/phone_agent_patches.py +146 -0
- AutoGLM_GUI/schemas.py +380 -2
- AutoGLM_GUI/state.py +21 -0
- AutoGLM_GUI/static/assets/{about-BOnRPlKQ.js → about-PcGX7dIG.js} +1 -1
- AutoGLM_GUI/static/assets/chat-B0FKL2ne.js +124 -0
- AutoGLM_GUI/static/assets/dialog-BSNX0L1i.js +45 -0
- AutoGLM_GUI/static/assets/index-BjYIY--m.css +1 -0
- AutoGLM_GUI/static/assets/index-CnEYDOXp.js +11 -0
- AutoGLM_GUI/static/assets/index-DOt5XNhh.js +1 -0
- AutoGLM_GUI/static/assets/logo-Cyfm06Ym.png +0 -0
- AutoGLM_GUI/static/assets/workflows-B1hgBC_O.js +1 -0
- AutoGLM_GUI/static/favicon.ico +0 -0
- AutoGLM_GUI/static/index.html +9 -2
- AutoGLM_GUI/static/logo-192.png +0 -0
- AutoGLM_GUI/static/logo-512.png +0 -0
- AutoGLM_GUI/workflow_manager.py +181 -0
- {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/METADATA +80 -35
- {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/RECORD +34 -19
- AutoGLM_GUI/static/assets/chat-CGW6uMKB.js +0 -149
- AutoGLM_GUI/static/assets/index-CRFVU0eu.js +0 -1
- AutoGLM_GUI/static/assets/index-DH-Dl4tK.js +0 -10
- AutoGLM_GUI/static/assets/index-DzUQ89YC.css +0 -1
- {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/WHEEL +0 -0
- {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/entry_points.txt +0 -0
- {autoglm_gui-1.0.2.dist-info → autoglm_gui-1.2.0.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/adb_plus/__init__.py
CHANGED
|
@@ -4,8 +4,12 @@ from .keyboard_installer import ADBKeyboardInstaller
|
|
|
4
4
|
from .screenshot import Screenshot, capture_screenshot
|
|
5
5
|
from .touch import touch_down, touch_move, touch_up
|
|
6
6
|
from .ip import get_wifi_ip
|
|
7
|
-
from .serial import get_device_serial
|
|
7
|
+
from .serial import get_device_serial, extract_serial_from_mdns
|
|
8
8
|
from .device import check_device_available
|
|
9
|
+
from .pair import pair_device
|
|
10
|
+
from .mdns import discover_mdns_devices, MdnsDevice
|
|
11
|
+
from .qr_pair import qr_pairing_manager
|
|
12
|
+
from .version import get_adb_version, supports_mdns_services
|
|
9
13
|
|
|
10
14
|
__all__ = [
|
|
11
15
|
"ADBKeyboardInstaller",
|
|
@@ -16,5 +20,12 @@ __all__ = [
|
|
|
16
20
|
"touch_up",
|
|
17
21
|
"get_wifi_ip",
|
|
18
22
|
"get_device_serial",
|
|
23
|
+
"extract_serial_from_mdns",
|
|
19
24
|
"check_device_available",
|
|
25
|
+
"pair_device",
|
|
26
|
+
"discover_mdns_devices",
|
|
27
|
+
"MdnsDevice",
|
|
28
|
+
"qr_pairing_manager",
|
|
29
|
+
"get_adb_version",
|
|
30
|
+
"supports_mdns_services",
|
|
20
31
|
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""mDNS-based ADB device discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from AutoGLM_GUI.platform_utils import run_cmd_silently_sync
|
|
10
|
+
|
|
11
|
+
__all__ = ["MdnsDevice", "discover_mdns_devices"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MdnsDevice:
|
|
16
|
+
"""Represents an mDNS-discovered ADB device."""
|
|
17
|
+
|
|
18
|
+
name: str # e.g., "adb-243a09b7-cbCO6P"
|
|
19
|
+
ip: str # e.g., "192.168.130.187"
|
|
20
|
+
port: int # e.g., 34553
|
|
21
|
+
has_pairing: bool # True if device also advertises pairing service
|
|
22
|
+
service_type: str # "_adb-tls-connect._tcp" or "_adb-tls-pairing._tcp"
|
|
23
|
+
pairing_port: Optional[int] = None # Pairing port if has_pairing is True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_mdns_line(line: str) -> tuple[str, str, str] | None:
|
|
27
|
+
"""
|
|
28
|
+
Parse a single mDNS service line.
|
|
29
|
+
|
|
30
|
+
Format: "name \t service_type \t ip:port"
|
|
31
|
+
Example: "adb-243a09b7-cbCO6P\t_adb-tls-connect._tcp\t192.168.130.187:34553"
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Tuple of (name, service_type, address) or None if invalid
|
|
35
|
+
"""
|
|
36
|
+
# Split by tab characters
|
|
37
|
+
parts = line.split("\t")
|
|
38
|
+
if len(parts) != 3:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
name = parts[0].strip()
|
|
42
|
+
service_type = parts[1].strip()
|
|
43
|
+
address = parts[2].strip()
|
|
44
|
+
|
|
45
|
+
# Validate we have all parts
|
|
46
|
+
if not (name and service_type and address):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
return name, service_type, address
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_address(address: str) -> tuple[str, int] | None:
|
|
53
|
+
"""
|
|
54
|
+
Parse IP:port from address string.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
address: Format "ip:port", e.g., "192.168.130.187:34553"
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (ip, port) or None if invalid or 0.0.0.0
|
|
61
|
+
"""
|
|
62
|
+
# Match IP:port pattern
|
|
63
|
+
match = re.match(r"^([\d.]+):(\d+)$", address)
|
|
64
|
+
if not match:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
ip = match.group(1)
|
|
68
|
+
port_str = match.group(2)
|
|
69
|
+
|
|
70
|
+
# Skip 0.0.0.0 addresses (device not properly initialized)
|
|
71
|
+
if ip == "0.0.0.0":
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Validate IP format
|
|
75
|
+
ip_pattern = r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
|
76
|
+
if not re.match(ip_pattern, ip):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# Validate IP octets
|
|
80
|
+
octets = ip.split(".")
|
|
81
|
+
if not all(0 <= int(octet) <= 255 for octet in octets):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# Parse port
|
|
85
|
+
try:
|
|
86
|
+
port = int(port_str)
|
|
87
|
+
if not (1 <= port <= 65535):
|
|
88
|
+
return None
|
|
89
|
+
except ValueError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
return ip, port
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def discover_mdns_devices(adb_path: str = "adb") -> list[MdnsDevice]:
|
|
96
|
+
"""
|
|
97
|
+
Discover wireless ADB devices via mDNS.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
adb_path: Path to adb executable
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of discovered devices, consolidated by device name
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
>>> devices = discover_mdns_devices()
|
|
107
|
+
>>> for dev in devices:
|
|
108
|
+
... print(f"{dev.name} at {dev.ip}:{dev.port}")
|
|
109
|
+
adb-243a09b7-cbCO6P at 192.168.130.187:34553
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
result = run_cmd_silently_sync([adb_path, "mdns", "services"], timeout=5)
|
|
113
|
+
|
|
114
|
+
# Check for errors
|
|
115
|
+
if result.returncode != 0:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
output = result.stdout
|
|
119
|
+
if not output:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
# Parse devices by name (consolidate multiple service types)
|
|
123
|
+
devices_dict: dict[str, dict] = {}
|
|
124
|
+
|
|
125
|
+
for line in output.splitlines():
|
|
126
|
+
# Skip header line
|
|
127
|
+
if "List of discovered mdns services" in line:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Parse line
|
|
131
|
+
parsed = _parse_mdns_line(line)
|
|
132
|
+
if not parsed:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
name, service_type, address = parsed
|
|
136
|
+
|
|
137
|
+
# Track pairing service separately (even if 0.0.0.0)
|
|
138
|
+
is_pairing_service = "_adb-tls-pairing._tcp" in service_type
|
|
139
|
+
is_connect_service = "_adb-tls-connect._tcp" in service_type
|
|
140
|
+
|
|
141
|
+
if name not in devices_dict:
|
|
142
|
+
devices_dict[name] = {
|
|
143
|
+
"name": name,
|
|
144
|
+
"ip": None,
|
|
145
|
+
"port": None,
|
|
146
|
+
"service_type": None,
|
|
147
|
+
"has_pairing": False,
|
|
148
|
+
"pairing_port": None,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# If this is a pairing service, mark it and save pairing port
|
|
152
|
+
if is_pairing_service:
|
|
153
|
+
devices_dict[name]["has_pairing"] = True
|
|
154
|
+
# Extract pairing port directly from address string (even if IP is 0.0.0.0)
|
|
155
|
+
if ":" in address:
|
|
156
|
+
try:
|
|
157
|
+
port_str = address.split(":")[-1]
|
|
158
|
+
pairing_port = int(port_str)
|
|
159
|
+
if 1 <= pairing_port <= 65535:
|
|
160
|
+
devices_dict[name]["pairing_port"] = pairing_port
|
|
161
|
+
except ValueError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# Only use connect service for actual connection
|
|
165
|
+
# Parse address for connect service
|
|
166
|
+
addr_parsed = _parse_address(address)
|
|
167
|
+
if is_connect_service and addr_parsed:
|
|
168
|
+
ip, port = addr_parsed
|
|
169
|
+
devices_dict[name]["ip"] = ip
|
|
170
|
+
devices_dict[name]["port"] = port
|
|
171
|
+
devices_dict[name]["service_type"] = service_type
|
|
172
|
+
|
|
173
|
+
# Convert to MdnsDevice objects (filter out devices without valid address)
|
|
174
|
+
devices = []
|
|
175
|
+
for dev_data in devices_dict.values():
|
|
176
|
+
if dev_data["ip"] and dev_data["port"]:
|
|
177
|
+
devices.append(
|
|
178
|
+
MdnsDevice(
|
|
179
|
+
name=dev_data["name"],
|
|
180
|
+
ip=dev_data["ip"],
|
|
181
|
+
port=dev_data["port"],
|
|
182
|
+
has_pairing=dev_data["has_pairing"],
|
|
183
|
+
service_type=dev_data["service_type"],
|
|
184
|
+
pairing_port=dev_data["pairing_port"],
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return devices
|
|
189
|
+
|
|
190
|
+
except Exception:
|
|
191
|
+
# Return empty list on any error (timeout, command not found, etc.)
|
|
192
|
+
return []
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""ADB wireless pairing support for Android 11+."""
|
|
2
|
+
|
|
3
|
+
from AutoGLM_GUI.platform_utils import run_cmd_silently_sync
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pair_device(
|
|
7
|
+
ip: str,
|
|
8
|
+
port: int,
|
|
9
|
+
pairing_code: str,
|
|
10
|
+
adb_path: str = "adb",
|
|
11
|
+
) -> tuple[bool, str]:
|
|
12
|
+
"""
|
|
13
|
+
Pair with Android device using wireless debugging (Android 11+).
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
ip: Device IP address
|
|
17
|
+
port: Pairing port (NOT connection port, typically shown in "Pair device with code" dialog)
|
|
18
|
+
pairing_code: 6-digit pairing code from device
|
|
19
|
+
adb_path: Path to adb executable
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Tuple of (success, message)
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> pair_device("192.168.1.100", 37831, "197872")
|
|
26
|
+
(True, "Successfully paired to 192.168.1.100:37831")
|
|
27
|
+
"""
|
|
28
|
+
# Validate pairing code format (6 digits)
|
|
29
|
+
if not pairing_code.isdigit() or len(pairing_code) != 6:
|
|
30
|
+
return False, "Pairing code must be 6 digits"
|
|
31
|
+
|
|
32
|
+
address = f"{ip}:{port}"
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Execute: adb pair ip:port pairing_code
|
|
36
|
+
result = run_cmd_silently_sync(
|
|
37
|
+
[adb_path, "pair", address, pairing_code], timeout=30
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
output = result.stdout + result.stderr
|
|
41
|
+
|
|
42
|
+
# Check for success indicators
|
|
43
|
+
if "Successfully paired" in output or "success" in output.lower():
|
|
44
|
+
return True, f"Successfully paired to {address}"
|
|
45
|
+
elif "failed" in output.lower():
|
|
46
|
+
# Extract error details
|
|
47
|
+
if "pairing code" in output.lower():
|
|
48
|
+
return False, "Invalid pairing code"
|
|
49
|
+
elif "refused" in output.lower():
|
|
50
|
+
return (
|
|
51
|
+
False,
|
|
52
|
+
"Connection refused - check if wireless debugging is enabled",
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
return False, f"Pairing failed: {output.strip()}"
|
|
56
|
+
else:
|
|
57
|
+
return False, output.strip() or "Unknown pairing error"
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return False, f"Pairing error: {e}"
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""QR code-based wireless ADB pairing module.
|
|
2
|
+
|
|
3
|
+
This module provides QR code pairing functionality for Android 11+ devices
|
|
4
|
+
with wireless debugging support. It generates QR codes and listens for mDNS
|
|
5
|
+
service advertisements to automatically pair and connect devices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import secrets
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Dict, Optional, Set, Tuple
|
|
17
|
+
|
|
18
|
+
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
|
|
19
|
+
|
|
20
|
+
from AutoGLM_GUI.logger import logger
|
|
21
|
+
from AutoGLM_GUI.platform_utils import run_cmd_silently_sync
|
|
22
|
+
|
|
23
|
+
# mDNS service types
|
|
24
|
+
PAIR_SERVICE_TYPE = "_adb-tls-pairing._tcp.local."
|
|
25
|
+
CONNECT_SERVICE_TYPE = "_adb-tls-connect._tcp.local."
|
|
26
|
+
|
|
27
|
+
# QR code payload format
|
|
28
|
+
QR_PAYLOAD_TEMPLATE = "WIFI:T:ADB;S:{name};P:{password};;"
|
|
29
|
+
|
|
30
|
+
# Success indicators in ADB output
|
|
31
|
+
SUCCESS_PAIR = "Successfully paired"
|
|
32
|
+
SUCCESS_CONNECT = "connected"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class PairingSession:
|
|
37
|
+
"""Represents an active QR pairing session."""
|
|
38
|
+
|
|
39
|
+
session_id: str
|
|
40
|
+
name: str # Session name (S field in QR code)
|
|
41
|
+
password: str # Pairing password (P field in QR code)
|
|
42
|
+
qr_payload: str # Full QR code payload
|
|
43
|
+
status: str # Current status: "listening" | "pairing" | "paired" | "connecting" | "connected" | "timeout" | "error"
|
|
44
|
+
device_id: Optional[str] = None # Device ID after connection (ip:port)
|
|
45
|
+
error_message: Optional[str] = None # Error details if status is "error"
|
|
46
|
+
created_at: float = field(default_factory=lambda: datetime.now().timestamp())
|
|
47
|
+
expires_at: float = 0.0 # Unix timestamp when session expires
|
|
48
|
+
zeroconf: Optional[Zeroconf] = None # Zeroconf instance
|
|
49
|
+
listener: Optional[QRPairingListener] = None # Service listener
|
|
50
|
+
thread: Optional[threading.Thread] = None # Listener thread
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pick_host_from_info(info) -> Optional[str]:
|
|
54
|
+
"""Extract preferred host from service info (IPv4 preferred)."""
|
|
55
|
+
try:
|
|
56
|
+
# Prefer IPv4 addresses
|
|
57
|
+
addrs = info.parsed_addresses()
|
|
58
|
+
for addr in addrs:
|
|
59
|
+
# Simple IPv4 pattern check
|
|
60
|
+
parts = addr.split(".")
|
|
61
|
+
if len(parts) == 4 and all(
|
|
62
|
+
p.isdigit() and 0 <= int(p) <= 255 for p in parts
|
|
63
|
+
):
|
|
64
|
+
return addr
|
|
65
|
+
# Fallback to first address
|
|
66
|
+
if addrs:
|
|
67
|
+
return addrs[0]
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# Fallback to mDNS hostname (.local.)
|
|
72
|
+
if hasattr(info, "server") and info.server:
|
|
73
|
+
return info.server.rstrip(".")
|
|
74
|
+
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _adb_pair(host: str, port: int, password: str, adb_path: str = "adb") -> bool:
|
|
79
|
+
"""Execute ADB pair command."""
|
|
80
|
+
logger.info(f"[QR Pair] Executing: adb pair {host}:{port}")
|
|
81
|
+
result = run_cmd_silently_sync(
|
|
82
|
+
[adb_path, "pair", f"{host}:{port}", password], timeout=25
|
|
83
|
+
)
|
|
84
|
+
output = result.stdout.strip()
|
|
85
|
+
logger.debug(f"[QR Pair] Pair output: {output}")
|
|
86
|
+
|
|
87
|
+
success = result.returncode == 0 and SUCCESS_PAIR in output
|
|
88
|
+
if success:
|
|
89
|
+
logger.info(f"[QR Pair] Successfully paired with {host}:{port}")
|
|
90
|
+
else:
|
|
91
|
+
logger.warning(f"[QR Pair] Pairing failed for {host}:{port}: {output}")
|
|
92
|
+
|
|
93
|
+
return success
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _adb_connect(host: str, port: int, adb_path: str = "adb") -> bool:
|
|
97
|
+
"""Execute ADB connect command."""
|
|
98
|
+
logger.info(f"[QR Pair] Executing: adb connect {host}:{port}")
|
|
99
|
+
result = run_cmd_silently_sync([adb_path, "connect", f"{host}:{port}"], timeout=20)
|
|
100
|
+
output = result.stdout.strip()
|
|
101
|
+
logger.debug(f"[QR Pair] Connect output: {output}")
|
|
102
|
+
|
|
103
|
+
success = result.returncode == 0 and SUCCESS_CONNECT in output.lower()
|
|
104
|
+
if success:
|
|
105
|
+
logger.info(f"[QR Pair] Successfully connected to {host}:{port}")
|
|
106
|
+
else:
|
|
107
|
+
logger.warning(f"[QR Pair] Connection failed for {host}:{port}: {output}")
|
|
108
|
+
|
|
109
|
+
return success
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class QRPairingListener(ServiceListener):
|
|
113
|
+
"""mDNS service listener for QR pairing."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, session: PairingSession, adb_path: str):
|
|
116
|
+
self.session = session
|
|
117
|
+
self.adb_path = adb_path
|
|
118
|
+
|
|
119
|
+
self.paired: bool = False
|
|
120
|
+
self.connected: bool = False
|
|
121
|
+
|
|
122
|
+
self.attempted_pair: Set[Tuple[str, int]] = set()
|
|
123
|
+
self.attempted_connect: Set[Tuple[str, int]] = set()
|
|
124
|
+
|
|
125
|
+
self.last_paired_host: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
def add_service(self, zc: Zeroconf, service_type: str, name: str) -> None:
|
|
128
|
+
"""Handle new service discovery."""
|
|
129
|
+
info = zc.get_service_info(service_type, name, timeout=3000)
|
|
130
|
+
if not info:
|
|
131
|
+
logger.debug(f"[QR Pair] No info for service: {name}")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
host = _pick_host_from_info(info)
|
|
135
|
+
if not host:
|
|
136
|
+
logger.debug(f"[QR Pair] No valid host for service: {name}")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
port = info.port
|
|
140
|
+
if port is None:
|
|
141
|
+
logger.debug(f"[QR Pair] No valid port for service: {name}")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
key = (host, port)
|
|
145
|
+
|
|
146
|
+
# Handle pairing service
|
|
147
|
+
if service_type == PAIR_SERVICE_TYPE and not self.paired:
|
|
148
|
+
if key in self.attempted_pair:
|
|
149
|
+
logger.debug(f"[QR Pair] Already attempted pairing for {host}:{port}")
|
|
150
|
+
return
|
|
151
|
+
self.attempted_pair.add(key)
|
|
152
|
+
|
|
153
|
+
logger.info(f"[QR Pair] Found pairing service: {name} -> {host}:{port}")
|
|
154
|
+
self.session.status = "pairing"
|
|
155
|
+
|
|
156
|
+
success = _adb_pair(host, port, self.session.password, self.adb_path)
|
|
157
|
+
if success:
|
|
158
|
+
self.paired = True
|
|
159
|
+
self.last_paired_host = host
|
|
160
|
+
self.session.status = "paired"
|
|
161
|
+
logger.info("[QR Pair] Pairing OK. Waiting for connect service...")
|
|
162
|
+
|
|
163
|
+
# Handle connect service
|
|
164
|
+
if service_type == CONNECT_SERVICE_TYPE and self.paired and not self.connected:
|
|
165
|
+
# Prefer same host as paired if we have it
|
|
166
|
+
if self.last_paired_host and host != self.last_paired_host:
|
|
167
|
+
logger.debug(
|
|
168
|
+
f"[QR Pair] Skipping connect service on different host: {host} (expected {self.last_paired_host})"
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if key in self.attempted_connect:
|
|
173
|
+
logger.debug(
|
|
174
|
+
f"[QR Pair] Already attempted connection for {host}:{port}"
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
self.attempted_connect.add(key)
|
|
178
|
+
|
|
179
|
+
logger.info(f"[QR Pair] Found connect service: {name} -> {host}:{port}")
|
|
180
|
+
self.session.status = "connecting"
|
|
181
|
+
|
|
182
|
+
success = _adb_connect(host, port, self.adb_path)
|
|
183
|
+
if success:
|
|
184
|
+
self.connected = True
|
|
185
|
+
self.session.status = "connected"
|
|
186
|
+
self.session.device_id = f"{host}:{port}"
|
|
187
|
+
logger.info(f"[QR Pair] Connected! Device ID: {self.session.device_id}")
|
|
188
|
+
|
|
189
|
+
def update_service(self, zc: Zeroconf, service_type: str, name: str) -> None:
|
|
190
|
+
"""Handle service updates (treat as adds)."""
|
|
191
|
+
self.add_service(zc, service_type, name)
|
|
192
|
+
|
|
193
|
+
def remove_service(self, _zc: Zeroconf, _service_type: str, _name: str) -> None:
|
|
194
|
+
"""Handle service removal (no action needed)."""
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class QRPairingManager:
|
|
199
|
+
"""Manages active QR pairing sessions."""
|
|
200
|
+
|
|
201
|
+
def __init__(self):
|
|
202
|
+
self._sessions: Dict[str, PairingSession] = {}
|
|
203
|
+
|
|
204
|
+
def create_session(
|
|
205
|
+
self, timeout: int = 90, adb_path: str = "adb"
|
|
206
|
+
) -> PairingSession:
|
|
207
|
+
"""Create a new pairing session with QR code.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
timeout: Session timeout in seconds (default 90)
|
|
211
|
+
adb_path: Path to ADB executable (default "adb")
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
PairingSession with generated QR code payload
|
|
215
|
+
"""
|
|
216
|
+
session_id = str(uuid.uuid4())
|
|
217
|
+
name = f"debug-{secrets.token_hex(4)}" # 8 hex chars
|
|
218
|
+
password = secrets.token_hex(8) # 16 hex chars (64-bit entropy)
|
|
219
|
+
qr_payload = QR_PAYLOAD_TEMPLATE.format(name=name, password=password)
|
|
220
|
+
|
|
221
|
+
now = datetime.now().timestamp()
|
|
222
|
+
session = PairingSession(
|
|
223
|
+
session_id=session_id,
|
|
224
|
+
name=name,
|
|
225
|
+
password=password,
|
|
226
|
+
qr_payload=qr_payload,
|
|
227
|
+
status="listening",
|
|
228
|
+
created_at=now,
|
|
229
|
+
expires_at=now + timeout,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Start mDNS listener in background thread
|
|
233
|
+
self._start_listener(session, adb_path)
|
|
234
|
+
|
|
235
|
+
self._sessions[session_id] = session
|
|
236
|
+
logger.info(f"[QR Pair] Created session {session_id} (timeout={timeout}s)")
|
|
237
|
+
logger.debug(f"[QR Pair] QR payload: {qr_payload}")
|
|
238
|
+
|
|
239
|
+
return session
|
|
240
|
+
|
|
241
|
+
def _start_listener(self, session: PairingSession, adb_path: str):
|
|
242
|
+
"""Start zeroconf listener (runs in thread pool to avoid blocking)."""
|
|
243
|
+
|
|
244
|
+
def _listen():
|
|
245
|
+
"""Listener thread function."""
|
|
246
|
+
try:
|
|
247
|
+
zc = Zeroconf()
|
|
248
|
+
listener = QRPairingListener(session, adb_path)
|
|
249
|
+
session.zeroconf = zc
|
|
250
|
+
session.listener = listener
|
|
251
|
+
|
|
252
|
+
# Register service browsers
|
|
253
|
+
ServiceBrowser(zc, PAIR_SERVICE_TYPE, listener)
|
|
254
|
+
ServiceBrowser(zc, CONNECT_SERVICE_TYPE, listener)
|
|
255
|
+
|
|
256
|
+
logger.info(
|
|
257
|
+
f"[QR Pair] Listening for mDNS services ({PAIR_SERVICE_TYPE}, {CONNECT_SERVICE_TYPE})"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Wait until timeout or connected
|
|
261
|
+
deadline = session.expires_at
|
|
262
|
+
while datetime.now().timestamp() < deadline:
|
|
263
|
+
if listener.connected:
|
|
264
|
+
logger.info("[QR Pair] Listener detected connection, stopping")
|
|
265
|
+
break
|
|
266
|
+
# Sleep in small increments to check frequently
|
|
267
|
+
import time
|
|
268
|
+
|
|
269
|
+
time.sleep(0.2)
|
|
270
|
+
|
|
271
|
+
# Check final status
|
|
272
|
+
if not listener.connected:
|
|
273
|
+
session.status = "timeout"
|
|
274
|
+
logger.warning(f"[QR Pair] Session {session.session_id} timed out")
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.exception(f"[QR Pair] Listener error: {e}")
|
|
278
|
+
session.status = "error"
|
|
279
|
+
session.error_message = str(e)
|
|
280
|
+
finally:
|
|
281
|
+
# Cleanup zeroconf
|
|
282
|
+
if session.zeroconf:
|
|
283
|
+
try:
|
|
284
|
+
session.zeroconf.close()
|
|
285
|
+
logger.debug(
|
|
286
|
+
f"[QR Pair] Zeroconf closed for session {session.session_id}"
|
|
287
|
+
)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
logger.error(f"[QR Pair] Error closing zeroconf: {e}")
|
|
290
|
+
|
|
291
|
+
# Run in dedicated thread (non-blocking for FastAPI)
|
|
292
|
+
thread = threading.Thread(
|
|
293
|
+
target=_listen,
|
|
294
|
+
name=f"QRPairing-{session.session_id[:8]}",
|
|
295
|
+
daemon=True, # 守护线程,主程序退出时自动终止
|
|
296
|
+
)
|
|
297
|
+
session.thread = thread
|
|
298
|
+
thread.start()
|
|
299
|
+
logger.debug(
|
|
300
|
+
f"[QR Pair] Started listener thread for session {session.session_id}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def get_session(self, session_id: str) -> Optional[PairingSession]:
|
|
304
|
+
"""Get session by ID.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
session_id: Session UUID
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
PairingSession if found, None otherwise
|
|
311
|
+
"""
|
|
312
|
+
return self._sessions.get(session_id)
|
|
313
|
+
|
|
314
|
+
def cancel_session(self, session_id: str) -> bool:
|
|
315
|
+
"""Cancel and cleanup a session.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
session_id: Session UUID to cancel
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
True if session was found and cancelled, False otherwise
|
|
322
|
+
"""
|
|
323
|
+
session = self._sessions.pop(session_id, None)
|
|
324
|
+
if session:
|
|
325
|
+
logger.info(f"[QR Pair] Cancelling session {session_id}")
|
|
326
|
+
|
|
327
|
+
# Close zeroconf (this will cause the listener thread to exit)
|
|
328
|
+
if session.zeroconf:
|
|
329
|
+
try:
|
|
330
|
+
session.zeroconf.close()
|
|
331
|
+
logger.debug(
|
|
332
|
+
f"[QR Pair] Zeroconf closed for cancelled session {session_id}"
|
|
333
|
+
)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(
|
|
336
|
+
f"[QR Pair] Error closing zeroconf during cancellation: {e}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Wait for thread to finish (with timeout)
|
|
340
|
+
if session.thread and session.thread.is_alive():
|
|
341
|
+
logger.debug("[QR Pair] Waiting for listener thread to finish...")
|
|
342
|
+
session.thread.join(timeout=2.0) # 最多等待2秒
|
|
343
|
+
if session.thread.is_alive():
|
|
344
|
+
logger.warning(
|
|
345
|
+
"[QR Pair] Listener thread did not finish in time (will be abandoned as daemon)"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return True
|
|
349
|
+
else:
|
|
350
|
+
logger.warning(f"[QR Pair] Session {session_id} not found for cancellation")
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
async def cleanup_expired_sessions(self):
|
|
354
|
+
"""Background task to cleanup expired sessions.
|
|
355
|
+
|
|
356
|
+
Runs indefinitely, checking every 60 seconds for expired sessions.
|
|
357
|
+
"""
|
|
358
|
+
logger.info("[QR Pair] Starting cleanup task")
|
|
359
|
+
while True:
|
|
360
|
+
await asyncio.sleep(60) # Check every minute
|
|
361
|
+
now = datetime.now().timestamp()
|
|
362
|
+
expired = [
|
|
363
|
+
sid for sid, sess in self._sessions.items() if now > sess.expires_at
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
for sid in expired:
|
|
367
|
+
logger.info(f"[QR Pair] Cleaning up expired session {sid}")
|
|
368
|
+
self.cancel_session(sid)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# Global singleton instance
|
|
372
|
+
qr_pairing_manager = QRPairingManager()
|