shareadb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shareadb-0.1.0/PKG-INFO +5 -0
- shareadb-0.1.0/README.md +139 -0
- shareadb-0.1.0/pyproject.toml +14 -0
- shareadb-0.1.0/setup.cfg +4 -0
- shareadb-0.1.0/src/shareadb/__init__.py +5 -0
- shareadb-0.1.0/src/shareadb/adb_client.py +210 -0
- shareadb-0.1.0/src/shareadb/cli.py +255 -0
- shareadb-0.1.0/src/shareadb/device_manager.py +286 -0
- shareadb-0.1.0/src/shareadb/tcp_proxy.py +119 -0
- shareadb-0.1.0/src/shareadb.egg-info/PKG-INFO +5 -0
- shareadb-0.1.0/src/shareadb.egg-info/SOURCES.txt +12 -0
- shareadb-0.1.0/src/shareadb.egg-info/dependency_links.txt +1 -0
- shareadb-0.1.0/src/shareadb.egg-info/entry_points.txt +2 -0
- shareadb-0.1.0/src/shareadb.egg-info/top_level.txt +1 -0
shareadb-0.1.0/PKG-INFO
ADDED
shareadb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# 功能需求
|
|
2
|
+
|
|
3
|
+
共享本地adb设备给其他用户使用。
|
|
4
|
+
|
|
5
|
+
## 需要考虑的问题
|
|
6
|
+
|
|
7
|
+
1. 多设备管理: 支持同时连接和管理多个adb设备。每个设备可以分配不同的端口号进行adb连接。
|
|
8
|
+
2. 跨平台支持, 需要支持windows, linux等操作系统
|
|
9
|
+
3. 一旦开启后,会记录已经开启的连接,如果设备断开后,可以自动进行重连(周期比如间隔5s检测设备是否已经连接上了, 用于维护连接的稳定性). 通知链接的断开和重新连接状态.
|
|
10
|
+
4. 可以针对单个已经连接的设备进行操作.
|
|
11
|
+
|
|
12
|
+
## 基础功能的实现步骤
|
|
13
|
+
|
|
14
|
+
检测adb,如果没有安装adb命令,那么则需要提示用户安装或者输入adb路径
|
|
15
|
+
|
|
16
|
+
当单个设备时
|
|
17
|
+
```bash
|
|
18
|
+
adb shell setprop persist.adb.tcp.port 5555
|
|
19
|
+
adb shell stop adbd
|
|
20
|
+
adb shell start adbd
|
|
21
|
+
adb forward tcp:6000 tcp:5555
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
多个设备希望端口号可以慢慢自增
|
|
25
|
+
|
|
26
|
+
最终还需要在本地启动一个代理,允许其他设备连接当前设备的IP来访问adb功能.
|
|
27
|
+
|
|
28
|
+
类似:
|
|
29
|
+
```bash
|
|
30
|
+
socat -s tcp4-listen:7000,reuseaddr,fork tcp-connect:127.0.0.1:6000
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
不过不建议直接使用socat, 而是使用python利用socket来实现一个属于自己的代理,这样可以实现跨平台.
|
|
34
|
+
|
|
35
|
+
## 项目结构
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pyproject.toml # 项目元数据与命令行入口
|
|
39
|
+
src/shareadb/ # Python 源码目录
|
|
40
|
+
__init__.py
|
|
41
|
+
adb_client.py # 封装 adb 命令的异步客户端
|
|
42
|
+
cli.py # 命令行入口,负责参数解析与事件循环
|
|
43
|
+
device_manager.py # 设备会话管理、端口分配与自动重连逻辑
|
|
44
|
+
tcp_proxy.py # 基于 asyncio 的 TCP 代理实现
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 使用说明
|
|
48
|
+
|
|
49
|
+
1. 确保本机已安装 adb 并可以在命令行中访问,或在执行时使用 `--adb-path` 指定 adb 的完整路径。
|
|
50
|
+
2. 在仓库根目录执行:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m shareadb.cli --poll-interval 5 --status-interval 30
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
常用参数:
|
|
57
|
+
- `--listen-host`: 代理监听的地址,默认 `0.0.0.0`
|
|
58
|
+
- `--forward-base-port`: adb forward 起始端口,默认 `6000`
|
|
59
|
+
- `--proxy-base-port`: TCP 代理起始端口,默认 `7000`
|
|
60
|
+
- `--include`: 仅管理指定序列号的设备,例如 `--include SERIAL1 SERIAL2`
|
|
61
|
+
|
|
62
|
+
3. 程序会每隔 `poll-interval` 秒检测设备状态:
|
|
63
|
+
- 新设备上线时自动配置 `adb tcp`、建立端口转发并启动本地代理;
|
|
64
|
+
- 设备断开时释放 forward 与代理,下次检测到设备重新连接时继续复用历史端口;
|
|
65
|
+
- `status-interval` 控制定期输出设备状态日志,设置为 `0` 可关闭。
|
|
66
|
+
- 设备连接成功后,会自动显示本机的所有可访问 IP 地址和对应的连接指令。
|
|
67
|
+
|
|
68
|
+
远端用户只需连接到 `listen-host:proxy_port` 即可访问对应设备的 adb。每台设备都会分配一对唯一的 forward 与代理端口,并在设备重连后保持一致。
|
|
69
|
+
|
|
70
|
+
## 连接信息示例
|
|
71
|
+
|
|
72
|
+
当设备成功连接后,程序会自动输出类似以下信息:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
============================================================
|
|
76
|
+
ADB devices are now ready for remote connection!
|
|
77
|
+
Remote users can connect using:
|
|
78
|
+
|
|
79
|
+
Device: abc123def456
|
|
80
|
+
Model: Pixel 6
|
|
81
|
+
Proxy port: 7000
|
|
82
|
+
Connection commands:
|
|
83
|
+
adb connect 10.0.28.15:7000
|
|
84
|
+
adb connect 192.168.1.100:7000
|
|
85
|
+
|
|
86
|
+
Note: Use the appropriate IP address that is accessible from the remote machine
|
|
87
|
+
============================================================
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
无需手动查找本机 IP,程序会自动检测所有可用的网络接口 IP 地址,方便远端用户选择合适的地址进行连接。
|
|
91
|
+
|
|
92
|
+
## 打包和发布
|
|
93
|
+
|
|
94
|
+
### 构建分发包
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# 安装构建工具
|
|
98
|
+
pip install build twine
|
|
99
|
+
|
|
100
|
+
# 构建分发包
|
|
101
|
+
python -m build
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
构建完成后,会在 `dist/` 目录下生成 `.tar.gz` 和 `.whl` 文件。
|
|
105
|
+
|
|
106
|
+
### 上传到 PyPI
|
|
107
|
+
|
|
108
|
+
首先确保你已经注册了 PyPI 账号并配置了账号信息:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# 上传到测试 PyPI(用于测试)
|
|
112
|
+
python -m twine upload --repository testpypi dist/*
|
|
113
|
+
|
|
114
|
+
# 上传到正式 PyPI
|
|
115
|
+
python -m twine upload dist/*
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 从 PyPI 安装
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# 从正式 PyPI 安装
|
|
122
|
+
pip install shareadbpy
|
|
123
|
+
|
|
124
|
+
# 从测试 PyPI 安装
|
|
125
|
+
pip install --index-url https://test.pypi.org/simple/ shareadbpy
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
安装后可以直接使用命令:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
shareadbpy --poll-interval 5 --status-interval 30
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 已测试场景
|
|
135
|
+
|
|
136
|
+
- Ubuntu 下的单设备移除和恢复
|
|
137
|
+
- Ubuntu 下的双设备移除和恢复
|
|
138
|
+
- 使用 `--include` 参数在双设备环境下只共享指定设备
|
|
139
|
+
- 基础功能在 Linux 系统下运行正常
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shareadb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Share local adb devices with remote users via TCP proxy"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
# No external dependencies required; uses Python standard library only.
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
shareadbpy = "shareadb.cli:main"
|
shareadb-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Helpers for interacting with the adb command line tool."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Union
|
|
10
|
+
|
|
11
|
+
_LOG = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ADBCommandResult:
|
|
16
|
+
"""Result wrapper for adb command executions."""
|
|
17
|
+
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
returncode: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ADBCommandError(RuntimeError):
|
|
24
|
+
"""Raised when an adb command exits with a non-zero code."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, command: Sequence[str], result: ADBCommandResult):
|
|
27
|
+
message = (
|
|
28
|
+
f"Command {' '.join(command)} failed with exit code {result.returncode}.\n"
|
|
29
|
+
f"stdout: {result.stdout}\n"
|
|
30
|
+
f"stderr: {result.stderr}"
|
|
31
|
+
)
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.command = list(command)
|
|
34
|
+
self.result = result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ADBDeviceInfo:
|
|
39
|
+
"""Represents a single adb device entry."""
|
|
40
|
+
|
|
41
|
+
serial: str
|
|
42
|
+
state: str
|
|
43
|
+
product: Optional[str] = None
|
|
44
|
+
model: Optional[str] = None
|
|
45
|
+
device: Optional[str] = None
|
|
46
|
+
transport_id: Optional[str] = None
|
|
47
|
+
extra: Optional[Dict[str, str]] = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_ready(self) -> bool:
|
|
51
|
+
"""Whether this device is in an operational state."""
|
|
52
|
+
|
|
53
|
+
return self.state == "device"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ADBClient:
|
|
57
|
+
"""Thin asynchronous wrapper around the adb executable."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, adb_path: Union[Path, str]):
|
|
60
|
+
self._adb_path = Path(adb_path)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def adb_path(self) -> Path:
|
|
64
|
+
return self._adb_path
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def detect(default: Optional[str] = None) -> Path:
|
|
68
|
+
"""Resolve the adb executable location.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
default: Optional path provided by the user.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A ``Path`` pointing to the adb executable.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
FileNotFoundError: If adb cannot be located.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
if default:
|
|
81
|
+
candidate = Path(default).expanduser()
|
|
82
|
+
if candidate.is_file():
|
|
83
|
+
return candidate
|
|
84
|
+
raise FileNotFoundError(f"Provided adb path does not exist: {candidate}")
|
|
85
|
+
|
|
86
|
+
resolved = shutil.which("adb")
|
|
87
|
+
if not resolved:
|
|
88
|
+
raise FileNotFoundError(
|
|
89
|
+
"Could not locate 'adb'. Install Android Platform Tools or provide --adb-path."
|
|
90
|
+
)
|
|
91
|
+
return Path(resolved)
|
|
92
|
+
|
|
93
|
+
async def run(
|
|
94
|
+
self,
|
|
95
|
+
args: Sequence[str],
|
|
96
|
+
*,
|
|
97
|
+
device_serial: Optional[str] = None,
|
|
98
|
+
timeout: Optional[float] = None,
|
|
99
|
+
check: bool = True,
|
|
100
|
+
) -> ADBCommandResult:
|
|
101
|
+
"""Execute an adb sub-command asynchronously."""
|
|
102
|
+
|
|
103
|
+
cmd: List[str] = [str(self._adb_path)]
|
|
104
|
+
if device_serial:
|
|
105
|
+
cmd += ["-s", device_serial]
|
|
106
|
+
cmd.extend(args)
|
|
107
|
+
|
|
108
|
+
_LOG.debug("Running adb command: %s", " ".join(cmd))
|
|
109
|
+
process = await asyncio.create_subprocess_exec(
|
|
110
|
+
*cmd,
|
|
111
|
+
stdout=asyncio.subprocess.PIPE,
|
|
112
|
+
stderr=asyncio.subprocess.PIPE,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
stdout_raw, stderr_raw = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
process.kill()
|
|
119
|
+
await process.wait()
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
stdout = stdout_raw.decode("utf-8", errors="replace").strip()
|
|
123
|
+
stderr = stderr_raw.decode("utf-8", errors="replace").strip()
|
|
124
|
+
result = ADBCommandResult(stdout=stdout, stderr=stderr, returncode=process.returncode)
|
|
125
|
+
|
|
126
|
+
if check and process.returncode != 0:
|
|
127
|
+
raise ADBCommandError(cmd, result)
|
|
128
|
+
if result.stderr:
|
|
129
|
+
_LOG.debug("adb stderr: %s", result.stderr)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
async def shell(
|
|
133
|
+
self,
|
|
134
|
+
device_serial: str,
|
|
135
|
+
shell_args: Iterable[str],
|
|
136
|
+
*,
|
|
137
|
+
timeout: Optional[float] = None,
|
|
138
|
+
) -> ADBCommandResult:
|
|
139
|
+
"""Run an adb shell command on a specific device."""
|
|
140
|
+
|
|
141
|
+
args = ["shell", *shell_args]
|
|
142
|
+
return await self.run(args, device_serial=device_serial, timeout=timeout)
|
|
143
|
+
|
|
144
|
+
async def forward(
|
|
145
|
+
self,
|
|
146
|
+
device_serial: str,
|
|
147
|
+
local_port: int,
|
|
148
|
+
remote_port: int,
|
|
149
|
+
*,
|
|
150
|
+
replace: bool = True,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Create or replace a port forward between host and device."""
|
|
153
|
+
|
|
154
|
+
if replace:
|
|
155
|
+
try:
|
|
156
|
+
await self.run(
|
|
157
|
+
["forward", "--remove", f"tcp:{local_port}"],
|
|
158
|
+
device_serial=device_serial,
|
|
159
|
+
check=False,
|
|
160
|
+
)
|
|
161
|
+
except Exception: # pragma: no cover - defensive; run() already suppresses via check=False
|
|
162
|
+
_LOG.debug("Ignoring failure removing existing forward for port %s", local_port)
|
|
163
|
+
await self.run(
|
|
164
|
+
["forward", f"tcp:{local_port}", f"tcp:{remote_port}"],
|
|
165
|
+
device_serial=device_serial,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def remove_forward(self, device_serial: str, local_port: int) -> None:
|
|
169
|
+
"""Remove an adb forward if it exists."""
|
|
170
|
+
|
|
171
|
+
await self.run(
|
|
172
|
+
["forward", "--remove", f"tcp:{local_port}"],
|
|
173
|
+
device_serial=device_serial,
|
|
174
|
+
check=False,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def list_devices(self) -> List[ADBDeviceInfo]:
|
|
178
|
+
"""Return all devices reported by ``adb devices -l``."""
|
|
179
|
+
|
|
180
|
+
result = await self.run(["devices", "-l"])
|
|
181
|
+
return _parse_device_list(result.stdout)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_device_list(raw: str) -> List[ADBDeviceInfo]:
|
|
185
|
+
devices: List[ADBDeviceInfo] = []
|
|
186
|
+
for line in raw.splitlines():
|
|
187
|
+
line = line.strip()
|
|
188
|
+
if not line or line.startswith("List of devices attached"):
|
|
189
|
+
continue
|
|
190
|
+
parts = line.split()
|
|
191
|
+
if len(parts) < 2:
|
|
192
|
+
continue
|
|
193
|
+
serial, state, *rest = parts
|
|
194
|
+
fields: dict[str, str] = {}
|
|
195
|
+
for item in rest:
|
|
196
|
+
if ":" in item:
|
|
197
|
+
key, value = item.split(":", 1)
|
|
198
|
+
fields[key] = value
|
|
199
|
+
devices.append(
|
|
200
|
+
ADBDeviceInfo(
|
|
201
|
+
serial=serial,
|
|
202
|
+
state=state,
|
|
203
|
+
product=fields.get("product"),
|
|
204
|
+
model=fields.get("model"),
|
|
205
|
+
device=fields.get("device"),
|
|
206
|
+
transport_id=fields.get("transport_id"),
|
|
207
|
+
extra={k: v for k, v in fields.items() if k not in {"product", "model", "device", "transport_id"}},
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return devices
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Command line entry point for shareadb."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
from typing import Iterable, List, Optional
|
|
10
|
+
|
|
11
|
+
from .adb_client import ADBClient
|
|
12
|
+
from .device_manager import DeviceManager, DeviceStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_local_ips() -> List[str]:
|
|
16
|
+
"""Get all local IP addresses from network interfaces."""
|
|
17
|
+
ips = []
|
|
18
|
+
try:
|
|
19
|
+
# Try to get IPs from all network interfaces
|
|
20
|
+
hostname = socket.gethostname()
|
|
21
|
+
addr_info = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
|
|
22
|
+
|
|
23
|
+
# Also try to get IPs directly from hostname
|
|
24
|
+
try:
|
|
25
|
+
hostname_ip = socket.gethostbyname(hostname)
|
|
26
|
+
addr_info.extend([(socket.AF_INET, socket.SOCK_STREAM, 6, '', (hostname_ip, 0))])
|
|
27
|
+
except:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
for info in addr_info:
|
|
31
|
+
ip = info[4][0] if info[4] else None
|
|
32
|
+
if ip:
|
|
33
|
+
# Filter out IPv6 and loopback addresses
|
|
34
|
+
if ':' not in ip and not ip.startswith('127.'):
|
|
35
|
+
if ip not in ips:
|
|
36
|
+
ips.append(ip)
|
|
37
|
+
|
|
38
|
+
# If still no IPs, try common interface names
|
|
39
|
+
if not ips:
|
|
40
|
+
interfaces = ['eth0', 'en0', 'wlan0', 'enp*', 'wlp*']
|
|
41
|
+
for interface in interfaces:
|
|
42
|
+
try:
|
|
43
|
+
# Try to get IP by creating a socket and binding to interface
|
|
44
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
45
|
+
# This won't actually connect, just get interface info
|
|
46
|
+
s.connect(('8.8.8.8', 80))
|
|
47
|
+
ip = s.getsockname()[0]
|
|
48
|
+
s.close()
|
|
49
|
+
if ip and not ip.startswith('127.') and ip not in ips:
|
|
50
|
+
ips.append(ip)
|
|
51
|
+
break
|
|
52
|
+
except:
|
|
53
|
+
continue
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
_LOG.debug("Error detecting local IPs: %s", exc)
|
|
56
|
+
|
|
57
|
+
# Fallback to common local network IPs
|
|
58
|
+
if not ips:
|
|
59
|
+
_LOG.warning("Could not detect local IP address, using 0.0.0.0")
|
|
60
|
+
ips = ['0.0.0.0']
|
|
61
|
+
|
|
62
|
+
return sorted(ips)
|
|
63
|
+
|
|
64
|
+
_LOG = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
parser = argparse.ArgumentParser(description="Share local adb devices with remote users")
|
|
69
|
+
parser.add_argument("--adb-path", help="Path to adb executable; defaults to PATH lookup")
|
|
70
|
+
parser.add_argument("--listen-host", default="0.0.0.0", help="Host/IP for proxy listeners")
|
|
71
|
+
parser.add_argument("--device-tcp-port", type=int, default=5555, help="adb tcp port configured on devices")
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--forward-base-port",
|
|
74
|
+
type=int,
|
|
75
|
+
default=6000,
|
|
76
|
+
help="First local port for adb forward; increments per device",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--proxy-base-port",
|
|
80
|
+
type=int,
|
|
81
|
+
default=7000,
|
|
82
|
+
help="First proxy listening port; increments per device",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--poll-interval",
|
|
86
|
+
type=float,
|
|
87
|
+
default=5.0,
|
|
88
|
+
help="Seconds between adb device polling cycles",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--include",
|
|
92
|
+
nargs="*",
|
|
93
|
+
help="Optional list of device serials to manage (default: all detected devices)",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--log-level",
|
|
97
|
+
default="INFO",
|
|
98
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
99
|
+
help="Logging verbosity",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--status-interval",
|
|
103
|
+
type=float,
|
|
104
|
+
default=30.0,
|
|
105
|
+
help="Seconds between periodic status logs (0 to disable)",
|
|
106
|
+
)
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def main_async(args: argparse.Namespace) -> None:
|
|
111
|
+
adb_path = ADBClient.detect(args.adb_path)
|
|
112
|
+
adb_client = ADBClient(adb_path)
|
|
113
|
+
|
|
114
|
+
manager = DeviceManager(
|
|
115
|
+
adb_client,
|
|
116
|
+
listen_host=args.listen_host,
|
|
117
|
+
device_tcp_port=args.device_tcp_port,
|
|
118
|
+
forward_base_port=args.forward_base_port,
|
|
119
|
+
proxy_base_port=args.proxy_base_port,
|
|
120
|
+
poll_interval=args.poll_interval,
|
|
121
|
+
include_serials=args.include,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
loop = asyncio.get_running_loop()
|
|
125
|
+
stop_event = asyncio.Event()
|
|
126
|
+
|
|
127
|
+
def _request_shutdown() -> None:
|
|
128
|
+
_LOG.info("Shutdown requested via signal")
|
|
129
|
+
stop_event.set()
|
|
130
|
+
|
|
131
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
132
|
+
try:
|
|
133
|
+
loop.add_signal_handler(sig, _request_shutdown)
|
|
134
|
+
except NotImplementedError:
|
|
135
|
+
# Windows event loop may not support signal handlers; fallback to default behavior.
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
await manager.start()
|
|
139
|
+
|
|
140
|
+
status_task: Optional[asyncio.Task[None]] = None
|
|
141
|
+
if args.status_interval > 0:
|
|
142
|
+
status_task = asyncio.create_task(_status_logger(manager, args.status_interval))
|
|
143
|
+
|
|
144
|
+
# Monitor devices and print connection info when they become ready
|
|
145
|
+
print_task = asyncio.create_task(_print_connection_on_ready(manager, args.listen_host, args.proxy_base_port))
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
await stop_event.wait()
|
|
149
|
+
finally:
|
|
150
|
+
if status_task:
|
|
151
|
+
status_task.cancel()
|
|
152
|
+
await asyncio.gather(status_task, return_exceptions=True)
|
|
153
|
+
print_task.cancel()
|
|
154
|
+
await asyncio.gather(print_task, return_exceptions=True)
|
|
155
|
+
await manager.stop()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _status_logger(manager: DeviceManager, interval: float) -> None:
|
|
159
|
+
try:
|
|
160
|
+
while True:
|
|
161
|
+
await asyncio.sleep(interval)
|
|
162
|
+
statuses = manager.statuses()
|
|
163
|
+
if not statuses:
|
|
164
|
+
_LOG.info("No active adb devices detected")
|
|
165
|
+
else:
|
|
166
|
+
for status in statuses:
|
|
167
|
+
_LOG.info("%s", _format_status(status))
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _print_connection_on_ready(manager: DeviceManager, listen_host: str, proxy_base_port: int) -> None:
|
|
173
|
+
"""Monitor devices and print connection info when they become ready."""
|
|
174
|
+
# Track running devices to avoid duplicate prints
|
|
175
|
+
seen_running = set()
|
|
176
|
+
|
|
177
|
+
while not manager._stop_event.is_set():
|
|
178
|
+
statuses = manager.statuses()
|
|
179
|
+
has_new = False
|
|
180
|
+
|
|
181
|
+
for status in statuses:
|
|
182
|
+
if status.state.value == "running" and status.serial not in seen_running:
|
|
183
|
+
seen_running.add(status.serial)
|
|
184
|
+
has_new = True
|
|
185
|
+
|
|
186
|
+
# Print connection info if we have new running devices
|
|
187
|
+
if has_new:
|
|
188
|
+
_print_connection_info(manager, listen_host, proxy_base_port)
|
|
189
|
+
|
|
190
|
+
# Wait a bit before checking again
|
|
191
|
+
await asyncio.sleep(1.0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _print_connection_info(manager: DeviceManager, listen_host: str, proxy_base_port: int) -> None:
|
|
195
|
+
"""Print connection information for all active devices."""
|
|
196
|
+
local_ips = get_local_ips()
|
|
197
|
+
statuses = manager.statuses()
|
|
198
|
+
|
|
199
|
+
if not statuses:
|
|
200
|
+
_LOG.info("No adb devices detected yet. Waiting for devices...")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
_LOG.info("=" * 60)
|
|
204
|
+
_LOG.info("ADB devices are now ready for remote connection!")
|
|
205
|
+
_LOG.info("Remote users can connect using:")
|
|
206
|
+
_LOG.info("")
|
|
207
|
+
|
|
208
|
+
for status in statuses:
|
|
209
|
+
if status.state.value == "running":
|
|
210
|
+
_LOG.info("Device: %s", status.serial)
|
|
211
|
+
if status.model:
|
|
212
|
+
_LOG.info(" Model: %s", status.model)
|
|
213
|
+
_LOG.info(" Proxy port: %d", status.proxy_port)
|
|
214
|
+
_LOG.info(" Connection commands:")
|
|
215
|
+
for ip in local_ips:
|
|
216
|
+
_LOG.info(" adb connect %s:%d", ip, status.proxy_port)
|
|
217
|
+
_LOG.info("")
|
|
218
|
+
|
|
219
|
+
_LOG.info("Note: Use the appropriate IP address that is accessible from the remote machine")
|
|
220
|
+
_LOG.info("=" * 60)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _format_status(status: DeviceStatus) -> str:
|
|
224
|
+
parts: List[str] = [
|
|
225
|
+
f"serial={status.serial}",
|
|
226
|
+
f"state={status.state.value}",
|
|
227
|
+
f"proxy={status.proxy_port}",
|
|
228
|
+
f"forward={status.forward_port}",
|
|
229
|
+
f"tcp={status.tcp_port}",
|
|
230
|
+
]
|
|
231
|
+
if status.model:
|
|
232
|
+
parts.append(f"model={status.model}")
|
|
233
|
+
if status.product:
|
|
234
|
+
parts.append(f"product={status.product}")
|
|
235
|
+
if status.last_error:
|
|
236
|
+
parts.append(f"error={status.last_error}")
|
|
237
|
+
return " ".join(parts)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
241
|
+
parser = build_parser()
|
|
242
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
243
|
+
logging.basicConfig(
|
|
244
|
+
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
|
245
|
+
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
asyncio.run(main_async(args))
|
|
249
|
+
except KeyboardInterrupt:
|
|
250
|
+
_LOG.info("Interrupted by user")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Device management and orchestration for adb sharing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Dict, Iterable, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from .adb_client import ADBClient, ADBCommandError, ADBDeviceInfo
|
|
11
|
+
from .tcp_proxy import TCPProxyServer
|
|
12
|
+
|
|
13
|
+
_LOG = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DevicePorts:
|
|
18
|
+
"""Ports reserved for a specific device."""
|
|
19
|
+
|
|
20
|
+
forward: int
|
|
21
|
+
proxy: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionState(Enum):
|
|
25
|
+
WAITING = "waiting"
|
|
26
|
+
RUNNING = "running"
|
|
27
|
+
ERROR = "error"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DeviceStatus:
|
|
32
|
+
"""Public view of a device session."""
|
|
33
|
+
|
|
34
|
+
serial: str
|
|
35
|
+
state: SessionState
|
|
36
|
+
forward_port: int
|
|
37
|
+
proxy_port: int
|
|
38
|
+
tcp_port: int
|
|
39
|
+
model: Optional[str] = None
|
|
40
|
+
product: Optional[str] = None
|
|
41
|
+
last_error: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ADBDeviceSession:
|
|
45
|
+
"""Lifecycle handler for a single adb device."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
adb_client: ADBClient,
|
|
50
|
+
serial: str,
|
|
51
|
+
*,
|
|
52
|
+
device_tcp_port: int,
|
|
53
|
+
forward_port: int,
|
|
54
|
+
proxy_port: int,
|
|
55
|
+
listen_host: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._adb = adb_client
|
|
58
|
+
self._serial = serial
|
|
59
|
+
self._device_tcp_port = device_tcp_port
|
|
60
|
+
self._forward_port = forward_port
|
|
61
|
+
self._proxy_port = proxy_port
|
|
62
|
+
self._listen_host = listen_host
|
|
63
|
+
self._proxy = TCPProxyServer(
|
|
64
|
+
listen_host,
|
|
65
|
+
proxy_port,
|
|
66
|
+
"127.0.0.1",
|
|
67
|
+
forward_port,
|
|
68
|
+
name=f"{serial}:{proxy_port}->{forward_port}",
|
|
69
|
+
)
|
|
70
|
+
self._lock = asyncio.Lock()
|
|
71
|
+
self._state = SessionState.WAITING
|
|
72
|
+
self._last_error: Optional[str] = None
|
|
73
|
+
self._last_info: Optional[ADBDeviceInfo] = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def serial(self) -> str:
|
|
77
|
+
return self._serial
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def state(self) -> SessionState:
|
|
81
|
+
return self._state
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def forward_port(self) -> int:
|
|
85
|
+
return self._forward_port
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def proxy_port(self) -> int:
|
|
89
|
+
return self._proxy_port
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def tcp_port(self) -> int:
|
|
93
|
+
return self._device_tcp_port
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def last_error(self) -> Optional[str]:
|
|
97
|
+
return self._last_error
|
|
98
|
+
|
|
99
|
+
def describe(self) -> DeviceStatus:
|
|
100
|
+
info = self._last_info
|
|
101
|
+
return DeviceStatus(
|
|
102
|
+
serial=self._serial,
|
|
103
|
+
state=self._state,
|
|
104
|
+
forward_port=self._forward_port,
|
|
105
|
+
proxy_port=self._proxy_port,
|
|
106
|
+
tcp_port=self._device_tcp_port,
|
|
107
|
+
model=info.model if info else None,
|
|
108
|
+
product=info.product if info else None,
|
|
109
|
+
last_error=self._last_error,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def ensure_running(self, info: ADBDeviceInfo) -> None:
|
|
113
|
+
async with self._lock:
|
|
114
|
+
self._last_info = info
|
|
115
|
+
if self._state == SessionState.RUNNING:
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
await self._start()
|
|
119
|
+
self._state = SessionState.RUNNING
|
|
120
|
+
self._last_error = None
|
|
121
|
+
except Exception as exc: # pragma: no cover - relies on adb/hardware failures
|
|
122
|
+
self._last_error = str(exc)
|
|
123
|
+
self._state = SessionState.ERROR
|
|
124
|
+
_LOG.error("Failed to enable adb sharing for %s: %s", self._serial, exc)
|
|
125
|
+
await self._teardown_forward()
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
async def mark_unavailable(self, reason: str) -> None:
|
|
129
|
+
async with self._lock:
|
|
130
|
+
if self._state == SessionState.WAITING:
|
|
131
|
+
return
|
|
132
|
+
_LOG.info("Device %s unavailable (%s); tearing down proxy", self._serial, reason)
|
|
133
|
+
await self._stop()
|
|
134
|
+
self._state = SessionState.WAITING
|
|
135
|
+
|
|
136
|
+
async def shutdown(self) -> None:
|
|
137
|
+
async with self._lock:
|
|
138
|
+
await self._stop()
|
|
139
|
+
self._state = SessionState.WAITING
|
|
140
|
+
|
|
141
|
+
async def _start(self) -> None:
|
|
142
|
+
_LOG.info(
|
|
143
|
+
"Configuring adb over TCP for device %s (forward tcp:%s -> tcp:%s)",
|
|
144
|
+
self._serial,
|
|
145
|
+
self._forward_port,
|
|
146
|
+
self._device_tcp_port,
|
|
147
|
+
)
|
|
148
|
+
await self._adb.shell(
|
|
149
|
+
self._serial,
|
|
150
|
+
["setprop", "persist.adb.tcp.port", str(self._device_tcp_port)],
|
|
151
|
+
)
|
|
152
|
+
await self._adb.forward(self._serial, self._forward_port, self._device_tcp_port)
|
|
153
|
+
await self._proxy.start()
|
|
154
|
+
_LOG.info(
|
|
155
|
+
"Device %s shared on %s:%s (forward tcp:%s -> tcp:%s)",
|
|
156
|
+
self._serial,
|
|
157
|
+
self._listen_host,
|
|
158
|
+
self._proxy_port,
|
|
159
|
+
self._forward_port,
|
|
160
|
+
self._device_tcp_port,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def _stop(self) -> None:
|
|
164
|
+
await self._proxy.stop()
|
|
165
|
+
await self._teardown_forward()
|
|
166
|
+
|
|
167
|
+
async def _teardown_forward(self) -> None:
|
|
168
|
+
try:
|
|
169
|
+
await self._adb.remove_forward(self._serial, self._forward_port)
|
|
170
|
+
except ADBCommandError as exc: # pragma: no cover - occurs on unexpected adb failures
|
|
171
|
+
_LOG.debug("remove_forward failed for %s: %s", self._serial, exc)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class DeviceManager:
|
|
175
|
+
"""Coordinates adb devices and associated proxy sessions."""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
adb_client: ADBClient,
|
|
180
|
+
*,
|
|
181
|
+
listen_host: str,
|
|
182
|
+
device_tcp_port: int,
|
|
183
|
+
forward_base_port: int,
|
|
184
|
+
proxy_base_port: int,
|
|
185
|
+
poll_interval: float,
|
|
186
|
+
include_serials: Optional[Sequence[str]] = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
self._adb = adb_client
|
|
189
|
+
self._listen_host = listen_host
|
|
190
|
+
self._device_tcp_port = device_tcp_port
|
|
191
|
+
self._poll_interval = poll_interval
|
|
192
|
+
self._include_serials = set(include_serials or [])
|
|
193
|
+
self._sessions: Dict[str, ADBDeviceSession] = {}
|
|
194
|
+
self._port_state: Dict[str, DevicePorts] = {}
|
|
195
|
+
self._next_forward = forward_base_port
|
|
196
|
+
self._next_proxy = proxy_base_port
|
|
197
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
198
|
+
self._stop_event = asyncio.Event()
|
|
199
|
+
|
|
200
|
+
async def start(self) -> None:
|
|
201
|
+
if self._task:
|
|
202
|
+
return
|
|
203
|
+
self._stop_event.clear()
|
|
204
|
+
self._task = asyncio.create_task(self._run_loop())
|
|
205
|
+
|
|
206
|
+
async def stop(self) -> None:
|
|
207
|
+
self._stop_event.set()
|
|
208
|
+
if self._task:
|
|
209
|
+
await self._task
|
|
210
|
+
self._task = None
|
|
211
|
+
await self._shutdown_sessions()
|
|
212
|
+
|
|
213
|
+
async def _run_loop(self) -> None:
|
|
214
|
+
while not self._stop_event.is_set():
|
|
215
|
+
try:
|
|
216
|
+
await self._sync_devices()
|
|
217
|
+
except Exception as exc: # pragma: no cover - ensures background loop resilience
|
|
218
|
+
_LOG.exception("Unhandled error during device sync: %s", exc)
|
|
219
|
+
try:
|
|
220
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=self._poll_interval)
|
|
221
|
+
except asyncio.TimeoutError:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
async def _sync_devices(self) -> None:
|
|
225
|
+
try:
|
|
226
|
+
devices = await self._adb.list_devices()
|
|
227
|
+
except FileNotFoundError:
|
|
228
|
+
_LOG.error("adb executable not found during sync")
|
|
229
|
+
return
|
|
230
|
+
except ADBCommandError as exc:
|
|
231
|
+
_LOG.error("Failed to list devices: %s", exc)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if self._include_serials:
|
|
235
|
+
devices = [d for d in devices if d.serial in self._include_serials]
|
|
236
|
+
|
|
237
|
+
ready_devices = {device.serial: device for device in devices if device.is_ready}
|
|
238
|
+
seen_serials = set(devices_by_serial(devices))
|
|
239
|
+
|
|
240
|
+
# Start or refresh sessions for ready devices.
|
|
241
|
+
for serial, device in ready_devices.items():
|
|
242
|
+
session = self._sessions.get(serial)
|
|
243
|
+
if not session:
|
|
244
|
+
ports = self._ensure_ports(serial)
|
|
245
|
+
session = ADBDeviceSession(
|
|
246
|
+
self._adb,
|
|
247
|
+
serial,
|
|
248
|
+
device_tcp_port=self._device_tcp_port,
|
|
249
|
+
forward_port=ports.forward,
|
|
250
|
+
proxy_port=ports.proxy,
|
|
251
|
+
listen_host=self._listen_host,
|
|
252
|
+
)
|
|
253
|
+
self._sessions[serial] = session
|
|
254
|
+
try:
|
|
255
|
+
await session.ensure_running(device)
|
|
256
|
+
except Exception:
|
|
257
|
+
# ``ensure_running`` already logged details; keep looping for retries.
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Tear down sessions for devices no longer available.
|
|
261
|
+
for serial, session in list(self._sessions.items()):
|
|
262
|
+
if serial not in ready_devices and session.state == SessionState.RUNNING:
|
|
263
|
+
reason = "not listed" if serial not in seen_serials else "not ready"
|
|
264
|
+
await session.mark_unavailable(reason)
|
|
265
|
+
|
|
266
|
+
async def _shutdown_sessions(self) -> None:
|
|
267
|
+
for session in list(self._sessions.values()):
|
|
268
|
+
await session.shutdown()
|
|
269
|
+
|
|
270
|
+
def _ensure_ports(self, serial: str) -> DevicePorts:
|
|
271
|
+
ports = self._port_state.get(serial)
|
|
272
|
+
if ports:
|
|
273
|
+
return ports
|
|
274
|
+
ports = DevicePorts(forward=self._next_forward, proxy=self._next_proxy)
|
|
275
|
+
self._next_forward += 1
|
|
276
|
+
self._next_proxy += 1
|
|
277
|
+
self._port_state[serial] = ports
|
|
278
|
+
_LOG.debug("Allocated ports for %s: forward=%s proxy=%s", serial, ports.forward, ports.proxy)
|
|
279
|
+
return ports
|
|
280
|
+
|
|
281
|
+
def statuses(self) -> List[DeviceStatus]:
|
|
282
|
+
return [session.describe() for session in self._sessions.values()]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def devices_by_serial(devices: Iterable[ADBDeviceInfo]) -> List[str]:
|
|
286
|
+
return [device.serial for device in devices]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Async TCP proxy to bridge clients to forwarded adb ports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
_LOG = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TCPProxyServer:
|
|
12
|
+
"""Bidirectional TCP proxy implemented with asyncio streams."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
listen_host: str,
|
|
17
|
+
listen_port: int,
|
|
18
|
+
target_host: str,
|
|
19
|
+
target_port: int,
|
|
20
|
+
*,
|
|
21
|
+
name: Optional[str] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._listen_host = listen_host
|
|
24
|
+
self._listen_port = listen_port
|
|
25
|
+
self._target_host = target_host
|
|
26
|
+
self._target_port = target_port
|
|
27
|
+
self._server: Optional[asyncio.AbstractServer] = None
|
|
28
|
+
self._client_tasks: set[asyncio.Task[None]] = set()
|
|
29
|
+
self._name = name or f"proxy:{listen_port}->{target_port}"
|
|
30
|
+
self._lock = asyncio.Lock()
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def address(self) -> tuple[str, int]:
|
|
34
|
+
return self._listen_host, self._listen_port
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def name(self) -> str:
|
|
38
|
+
return self._name
|
|
39
|
+
|
|
40
|
+
async def start(self) -> None:
|
|
41
|
+
async with self._lock:
|
|
42
|
+
if self._server:
|
|
43
|
+
return
|
|
44
|
+
self._server = await asyncio.start_server(
|
|
45
|
+
self._handle_client,
|
|
46
|
+
host=self._listen_host,
|
|
47
|
+
port=self._listen_port,
|
|
48
|
+
)
|
|
49
|
+
addr = ", ".join(str(sock.getsockname()) for sock in self._server.sockets or [])
|
|
50
|
+
_LOG.info("Started proxy %s listening on %s", self._name, addr)
|
|
51
|
+
|
|
52
|
+
async def stop(self) -> None:
|
|
53
|
+
async with self._lock:
|
|
54
|
+
if not self._server:
|
|
55
|
+
return
|
|
56
|
+
server, self._server = self._server, None
|
|
57
|
+
server.close()
|
|
58
|
+
await server.wait_closed()
|
|
59
|
+
for task in list(self._client_tasks):
|
|
60
|
+
task.cancel()
|
|
61
|
+
if self._client_tasks:
|
|
62
|
+
await asyncio.gather(*self._client_tasks, return_exceptions=True)
|
|
63
|
+
self._client_tasks.clear()
|
|
64
|
+
_LOG.info("Stopped proxy %s", self._name)
|
|
65
|
+
|
|
66
|
+
async def _handle_client(
|
|
67
|
+
self,
|
|
68
|
+
client_reader: asyncio.StreamReader,
|
|
69
|
+
client_writer: asyncio.StreamWriter,
|
|
70
|
+
) -> None:
|
|
71
|
+
peer = client_writer.get_extra_info("peername")
|
|
72
|
+
_LOG.debug("Accepted connection %s -> %s", peer, self._name)
|
|
73
|
+
try:
|
|
74
|
+
upstream_reader, upstream_writer = await asyncio.open_connection(
|
|
75
|
+
host=self._target_host,
|
|
76
|
+
port=self._target_port,
|
|
77
|
+
)
|
|
78
|
+
except Exception as exc: # pragma: no cover - network failures hard to deterministically test
|
|
79
|
+
_LOG.warning(
|
|
80
|
+
"Proxy %s failed to connect to target %s:%s: %s",
|
|
81
|
+
self._name,
|
|
82
|
+
self._target_host,
|
|
83
|
+
self._target_port,
|
|
84
|
+
exc,
|
|
85
|
+
)
|
|
86
|
+
client_writer.close()
|
|
87
|
+
await client_writer.wait_closed()
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
async def pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
91
|
+
try:
|
|
92
|
+
while True:
|
|
93
|
+
data = await reader.read(65536)
|
|
94
|
+
if not data:
|
|
95
|
+
break
|
|
96
|
+
writer.write(data)
|
|
97
|
+
await writer.drain()
|
|
98
|
+
except asyncio.CancelledError: # Propagate cancellation cleanly
|
|
99
|
+
raise
|
|
100
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
101
|
+
_LOG.debug("Proxy %s pipe error: %s", self._name, exc)
|
|
102
|
+
finally:
|
|
103
|
+
try:
|
|
104
|
+
writer.close()
|
|
105
|
+
await writer.wait_closed()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
task_down = asyncio.create_task(pipe(client_reader, upstream_writer))
|
|
110
|
+
task_up = asyncio.create_task(pipe(upstream_reader, client_writer))
|
|
111
|
+
grouped = asyncio.gather(task_down, task_up, return_exceptions=True)
|
|
112
|
+
self._client_tasks.add(task_down)
|
|
113
|
+
self._client_tasks.add(task_up)
|
|
114
|
+
try:
|
|
115
|
+
await grouped
|
|
116
|
+
finally:
|
|
117
|
+
self._client_tasks.discard(task_down)
|
|
118
|
+
self._client_tasks.discard(task_up)
|
|
119
|
+
_LOG.debug("Closed connection %s -> %s", peer, self._name)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/shareadb/__init__.py
|
|
4
|
+
src/shareadb/adb_client.py
|
|
5
|
+
src/shareadb/cli.py
|
|
6
|
+
src/shareadb/device_manager.py
|
|
7
|
+
src/shareadb/tcp_proxy.py
|
|
8
|
+
src/shareadb.egg-info/PKG-INFO
|
|
9
|
+
src/shareadb.egg-info/SOURCES.txt
|
|
10
|
+
src/shareadb.egg-info/dependency_links.txt
|
|
11
|
+
src/shareadb.egg-info/entry_points.txt
|
|
12
|
+
src/shareadb.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shareadb
|