shareadb 0.1.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.
shareadb/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """shareadb package: share local adb devices via TCP proxies."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
shareadb/adb_client.py 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
shareadb/cli.py ADDED
@@ -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]
shareadb/tcp_proxy.py ADDED
@@ -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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: shareadb
3
+ Version: 0.1.0
4
+ Summary: Share local adb devices with remote users via TCP proxy
5
+ Requires-Python: >=3.8
@@ -0,0 +1,10 @@
1
+ shareadb/__init__.py,sha256=aqRwNaVatoHg3FiJ-KRaSHucXz8lN8UebdJ_wQsWGPQ,115
2
+ shareadb/adb_client.py,sha256=SEFtpbbbogo0O8Lcr1EX4OTvP95wBMyGwdydq9oO6TE,6448
3
+ shareadb/cli.py,sha256=GWYkCsONFM8NQcfE5Z_yYpCOW6qPHIqKouxUjJJveHI,8715
4
+ shareadb/device_manager.py,sha256=U41N5ve9HNYmi8RMzrqpxoUplOncurWT2mlhb1E1ibE,9441
5
+ shareadb/tcp_proxy.py,sha256=2CmQ62TD8gecNuJgTiLtrhw6yKscqVFbRMgy01MxhY0,4242
6
+ shareadb-0.1.0.dist-info/METADATA,sha256=bIkyea991BqxfcwQkw02s5GBl0qNmH5AXi51CE2cotw,140
7
+ shareadb-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ shareadb-0.1.0.dist-info/entry_points.txt,sha256=rpns9Fg7HkpP9yZlI4qrHxfiTKFO_y2fsu-pjIHXFR4,49
9
+ shareadb-0.1.0.dist-info/top_level.txt,sha256=_Kre6pE0IGMw6atkcYwHFk0X977MTw-x3C1uA1-Gfn4,9
10
+ shareadb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shareadbpy = shareadb.cli:main
@@ -0,0 +1 @@
1
+ shareadb