phoneview 1.0.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.
- phoneview/__init__.py +2 -0
- phoneview/__main__.py +3 -0
- phoneview/android/__init__.py +0 -0
- phoneview/android/adb.py +198 -0
- phoneview/android/backend.py +432 -0
- phoneview/android/scrcpy.py +233 -0
- phoneview/backends/__init__.py +0 -0
- phoneview/backends/auto.py +152 -0
- phoneview/backends/base.py +104 -0
- phoneview/events.py +103 -0
- phoneview/httpmini.py +86 -0
- phoneview/ios/__init__.py +0 -0
- phoneview/ios/backend.py +616 -0
- phoneview/ios/connection.py +106 -0
- phoneview/ios/developer_mode.py +86 -0
- phoneview/ios/hid.py +185 -0
- phoneview/ios/stream.py +225 -0
- phoneview/ios/wda.py +208 -0
- phoneview/media.py +150 -0
- phoneview/security.py +134 -0
- phoneview/server.py +231 -0
- phoneview/session.py +201 -0
- phoneview/static/index.html +66 -0
- phoneview/static/viewer.css +35 -0
- phoneview/static/viewer.js +473 -0
- phoneview/streaming.py +50 -0
- phoneview-1.0.0.dist-info/METADATA +317 -0
- phoneview-1.0.0.dist-info/RECORD +32 -0
- phoneview-1.0.0.dist-info/WHEEL +5 -0
- phoneview-1.0.0.dist-info/entry_points.txt +2 -0
- phoneview-1.0.0.dist-info/licenses/LICENSE +201 -0
- phoneview-1.0.0.dist-info/top_level.txt +1 -0
phoneview/__init__.py
ADDED
phoneview/__main__.py
ADDED
|
File without changes
|
phoneview/android/adb.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Minimal asyncio client for the local adb server (smart-socket protocol, port 5037).
|
|
2
|
+
|
|
3
|
+
Talking to the adb server directly avoids parsing CLI output that differs between
|
|
4
|
+
platform-tools versions. The `adb` binary is only used to start the server when
|
|
5
|
+
it is not already running.
|
|
6
|
+
|
|
7
|
+
Protocol: https://android.googlesource.com/platform/packages/modules/adb/+/refs/heads/main/SERVICES.TXT
|
|
8
|
+
and .../SYNC.TXT
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import struct
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
21
|
+
DEFAULT_PORT = 5037
|
|
22
|
+
SYNC_DATA_MAX = 64 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AdbError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AdbUnavailableError(AdbError):
|
|
30
|
+
"""The adb server is not running and could not be started."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AdbDevice:
|
|
35
|
+
serial: str
|
|
36
|
+
state: str # device, unauthorized, offline, no permissions, ...
|
|
37
|
+
info: dict = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def usb(self):
|
|
41
|
+
# Judge by the serial: TCP serials are host:port, wireless-debugging (mDNS)
|
|
42
|
+
# serials contain "._adb", emulators start with "emulator-". The usb:<bus>
|
|
43
|
+
# field of devices-l is only a positive hint: Windows adb never prints it.
|
|
44
|
+
if "usb" in self.info:
|
|
45
|
+
return True
|
|
46
|
+
return (":" not in self.serial and not self.serial.startswith("emulator-")
|
|
47
|
+
and "._adb" not in self.serial)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AdbClient:
|
|
51
|
+
def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, adb_path=None):
|
|
52
|
+
self.host = host
|
|
53
|
+
self.port = port
|
|
54
|
+
self.adb_path = adb_path or os.environ.get("ADB") or shutil.which("adb")
|
|
55
|
+
self._last_start_attempt = 0.0
|
|
56
|
+
|
|
57
|
+
# -- connection helpers ---------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def _open(self):
|
|
60
|
+
try:
|
|
61
|
+
return await asyncio.wait_for(asyncio.open_connection(self.host, self.port), 3)
|
|
62
|
+
except (ConnectionRefusedError, OSError, asyncio.TimeoutError):
|
|
63
|
+
await self._start_server()
|
|
64
|
+
try:
|
|
65
|
+
return await asyncio.wait_for(asyncio.open_connection(self.host, self.port), 3)
|
|
66
|
+
except (ConnectionRefusedError, OSError, asyncio.TimeoutError) as exc:
|
|
67
|
+
raise AdbUnavailableError("adb server is not running") from exc
|
|
68
|
+
|
|
69
|
+
async def _start_server(self):
|
|
70
|
+
now = time.monotonic()
|
|
71
|
+
if not self.adb_path or now - self._last_start_attempt < 10:
|
|
72
|
+
if not self.adb_path:
|
|
73
|
+
raise AdbUnavailableError(
|
|
74
|
+
"adb was not found. Install Android platform-tools (Linux: 'sudo apt install adb') "
|
|
75
|
+
"or set the ADB environment variable to its path.")
|
|
76
|
+
raise AdbUnavailableError("adb server is not running")
|
|
77
|
+
self._last_start_attempt = now
|
|
78
|
+
log.info("Starting adb server")
|
|
79
|
+
proc = await asyncio.create_subprocess_exec(
|
|
80
|
+
self.adb_path, "start-server",
|
|
81
|
+
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
|
82
|
+
try:
|
|
83
|
+
await asyncio.wait_for(proc.wait(), 15)
|
|
84
|
+
except asyncio.TimeoutError:
|
|
85
|
+
proc.kill()
|
|
86
|
+
raise AdbUnavailableError("adb start-server timed out")
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
async def _send(reader, writer, request):
|
|
90
|
+
data = request.encode("utf-8")
|
|
91
|
+
writer.write(b"%04x" % len(data) + data)
|
|
92
|
+
await writer.drain()
|
|
93
|
+
status = await reader.readexactly(4)
|
|
94
|
+
if status == b"OKAY":
|
|
95
|
+
return
|
|
96
|
+
if status == b"FAIL":
|
|
97
|
+
raise AdbError(await AdbClient._read_string(reader))
|
|
98
|
+
raise AdbError(f"Unexpected adb response {status!r}")
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
async def _read_string(reader):
|
|
102
|
+
length = int(await reader.readexactly(4), 16)
|
|
103
|
+
return (await reader.readexactly(length)).decode("utf-8", "replace")
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _close(writer):
|
|
107
|
+
try:
|
|
108
|
+
writer.close()
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# -- host services ----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async def devices(self):
|
|
115
|
+
reader, writer = await self._open()
|
|
116
|
+
try:
|
|
117
|
+
await self._send(reader, writer, "host:devices-l")
|
|
118
|
+
text = await self._read_string(reader)
|
|
119
|
+
finally:
|
|
120
|
+
self._close(writer)
|
|
121
|
+
return parse_devices(text)
|
|
122
|
+
|
|
123
|
+
async def open_stream(self, serial, service, timeout=5):
|
|
124
|
+
"""Connect to `service` on the device; returns (reader, writer)."""
|
|
125
|
+
reader, writer = await self._open()
|
|
126
|
+
try:
|
|
127
|
+
await asyncio.wait_for(self._send(reader, writer, f"host:transport:{serial}"), timeout)
|
|
128
|
+
await asyncio.wait_for(self._send(reader, writer, service), timeout)
|
|
129
|
+
except BaseException:
|
|
130
|
+
self._close(writer)
|
|
131
|
+
raise
|
|
132
|
+
return reader, writer
|
|
133
|
+
|
|
134
|
+
async def shell(self, serial, command, timeout=10, limit=1 << 20):
|
|
135
|
+
reader, writer = await self.open_stream(serial, f"shell:{command}")
|
|
136
|
+
try:
|
|
137
|
+
data = await asyncio.wait_for(reader.read(limit), timeout)
|
|
138
|
+
chunks = [data]
|
|
139
|
+
while data:
|
|
140
|
+
data = await asyncio.wait_for(reader.read(limit), timeout)
|
|
141
|
+
chunks.append(data)
|
|
142
|
+
return b"".join(chunks).decode("utf-8", "replace")
|
|
143
|
+
finally:
|
|
144
|
+
self._close(writer)
|
|
145
|
+
|
|
146
|
+
async def getprops(self, serial, names):
|
|
147
|
+
out = {}
|
|
148
|
+
for name in names:
|
|
149
|
+
out[name] = (await self.shell(serial, f"getprop {name}")).strip()
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
async def push(self, serial, data, remote_path, mode=0o644, timeout=30):
|
|
153
|
+
"""Push bytes to `remote_path` using the sync protocol."""
|
|
154
|
+
reader, writer = await self.open_stream(serial, "sync:")
|
|
155
|
+
try:
|
|
156
|
+
spec = f"{remote_path},{mode}".encode()
|
|
157
|
+
writer.write(b"SEND" + struct.pack("<I", len(spec)) + spec)
|
|
158
|
+
for offset in range(0, len(data), SYNC_DATA_MAX):
|
|
159
|
+
chunk = data[offset:offset + SYNC_DATA_MAX]
|
|
160
|
+
writer.write(b"DATA" + struct.pack("<I", len(chunk)) + chunk)
|
|
161
|
+
await writer.drain()
|
|
162
|
+
writer.write(b"DONE" + struct.pack("<I", int(time.time())))
|
|
163
|
+
await writer.drain()
|
|
164
|
+
reply = await asyncio.wait_for(reader.readexactly(8), timeout)
|
|
165
|
+
if reply[:4] == b"FAIL":
|
|
166
|
+
length, = struct.unpack("<I", reply[4:])
|
|
167
|
+
message = (await reader.readexactly(length)).decode("utf-8", "replace")
|
|
168
|
+
raise AdbError(f"push failed: {message}")
|
|
169
|
+
if reply[:4] != b"OKAY":
|
|
170
|
+
raise AdbError(f"Unexpected sync reply {reply[:4]!r}")
|
|
171
|
+
writer.write(b"QUIT" + struct.pack("<I", 0))
|
|
172
|
+
await writer.drain()
|
|
173
|
+
finally:
|
|
174
|
+
self._close(writer)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_devices(text):
|
|
178
|
+
"""Parse `host:devices-l` output into AdbDevice objects."""
|
|
179
|
+
devices = []
|
|
180
|
+
for line in text.splitlines():
|
|
181
|
+
line = line.strip()
|
|
182
|
+
if not line:
|
|
183
|
+
continue
|
|
184
|
+
# States can contain spaces ("no permissions (...)"); info fields are key:value.
|
|
185
|
+
serial, _, rest = line.partition(" ")
|
|
186
|
+
if not serial:
|
|
187
|
+
continue
|
|
188
|
+
rest = rest.strip()
|
|
189
|
+
info = {}
|
|
190
|
+
state_words = []
|
|
191
|
+
for word in rest.split():
|
|
192
|
+
key, sep, value = word.partition(":")
|
|
193
|
+
if sep and key in {"usb", "product", "model", "device", "transport_id"}:
|
|
194
|
+
info[key] = value
|
|
195
|
+
else:
|
|
196
|
+
state_words.append(word)
|
|
197
|
+
devices.append(AdbDevice(serial, " ".join(state_words) or "unknown", info))
|
|
198
|
+
return devices
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Android backend: scrcpy server over adb, H.264 relayed untouched to the browser.
|
|
2
|
+
|
|
3
|
+
The host never decodes or re-encodes video, so a Raspberry Pi or thin client only
|
|
4
|
+
copies packets. The scrcpy server runs only while a browser is watching (plus a
|
|
5
|
+
short grace period), so an idle phone is not encoding its screen.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import secrets
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
|
|
14
|
+
from phoneview.android import scrcpy
|
|
15
|
+
from phoneview.android.adb import AdbClient, AdbError, AdbUnavailableError
|
|
16
|
+
from phoneview.backends.base import Backend, capabilities, setting
|
|
17
|
+
from phoneview.events import COORD_MAX
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
MAX_SIZES = (0, 1920, 1600, 1280, 1024, 800, 640)
|
|
22
|
+
FPS_CHOICES = (15, 24, 30, 45, 60)
|
|
23
|
+
FALLBACK_SIZES = (1024, 800, 640)
|
|
24
|
+
VIEWER_GRACE_SECONDS = 10
|
|
25
|
+
CONTROL_QUEUE_LIMIT = 256
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StreamEnded(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AndroidSettings:
|
|
34
|
+
max_size: int = 1280 # long edge in pixels; 0 = native
|
|
35
|
+
max_fps: int = 30
|
|
36
|
+
bit_rate_mbps: float = 4.0
|
|
37
|
+
serial: str = None
|
|
38
|
+
server_path: str = None
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
if self.max_size not in MAX_SIZES:
|
|
42
|
+
raise ValueError(f"max_size must be one of {MAX_SIZES}")
|
|
43
|
+
if self.max_fps not in FPS_CHOICES:
|
|
44
|
+
raise ValueError(f"max_fps must be one of {FPS_CHOICES}")
|
|
45
|
+
if type(self.bit_rate_mbps) not in (int, float) or not 0.5 <= self.bit_rate_mbps <= 16:
|
|
46
|
+
raise ValueError("Bit rate must be 0.5..16 Mbps")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def device_problem(state):
|
|
50
|
+
"""User-facing guidance for adb device states other than 'device'."""
|
|
51
|
+
if state == "unauthorized":
|
|
52
|
+
return "Unlock the phone and accept the 'Allow USB debugging?' prompt (tick 'Always allow')."
|
|
53
|
+
if state.startswith("no permissions"):
|
|
54
|
+
return ("The host has no permission to open the USB device. On Linux add a udev rule for "
|
|
55
|
+
"Android devices (e.g. install the 'android-sdk-platform-tools-common' package) "
|
|
56
|
+
"and replug the phone.")
|
|
57
|
+
if state == "offline":
|
|
58
|
+
return "The phone is offline to adb. Replug the USB cable or toggle USB debugging."
|
|
59
|
+
if state in {"authorizing", "connecting"}:
|
|
60
|
+
return "Waiting for the phone to finish USB debugging authorisation..."
|
|
61
|
+
return f"Android device is in state '{state}'."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AndroidBackend(Backend):
|
|
65
|
+
platform = "android"
|
|
66
|
+
|
|
67
|
+
def __init__(self, settings=None, hub=None, adb=None):
|
|
68
|
+
super().__init__(hub)
|
|
69
|
+
self.settings = settings or AndroidSettings()
|
|
70
|
+
self.adb = adb or AdbClient()
|
|
71
|
+
self.serial = None
|
|
72
|
+
self.streaming = False
|
|
73
|
+
self.width = self.height = 0
|
|
74
|
+
self.control = None # asyncio.Queue of bytes while streaming
|
|
75
|
+
self.pointers = {} # browser pointer id -> (x, y) in video pixels
|
|
76
|
+
self._config = b""
|
|
77
|
+
self._last_key = 0.0
|
|
78
|
+
self._restart = asyncio.Event()
|
|
79
|
+
self._fallback = 0
|
|
80
|
+
self._last_viewer = 0.0
|
|
81
|
+
self._rate_window = (time.monotonic(), 0, 0)
|
|
82
|
+
self._rates = (0.0, 0.0)
|
|
83
|
+
self.hub.request_keyframe = self._request_keyframe
|
|
84
|
+
|
|
85
|
+
# -- capabilities / settings ---------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def capabilities(self):
|
|
88
|
+
s = self.settings
|
|
89
|
+
return capabilities(
|
|
90
|
+
video="h264" if self.device else None, touch=True, multitouch=True, live_touch=True,
|
|
91
|
+
keys=set(scrcpy.KEYCODES) | {"siri"}, text=True, scroll=True, clipboard=True,
|
|
92
|
+
commands={"expand_notifications", "expand_settings", "collapse_panels", "rotate",
|
|
93
|
+
"screen_off", "screen_on"},
|
|
94
|
+
settings=[
|
|
95
|
+
setting("max_size", "Max size (long edge)", s.max_size,
|
|
96
|
+
options=[(v, "Native" if v == 0 else f"{v} px") for v in MAX_SIZES]),
|
|
97
|
+
setting("max_fps", "Max fps", s.max_fps, options=[(v, str(v)) for v in FPS_CHOICES]),
|
|
98
|
+
setting("bit_rate_mbps", "Bit rate (Mbps)", s.bit_rate_mbps, minimum=0.5, maximum=16, step=0.5),
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def update_settings(self, values):
|
|
103
|
+
if not isinstance(values, dict) or not set(values) <= {"max_size", "max_fps", "bit_rate_mbps"}:
|
|
104
|
+
raise ValueError("Unknown Android settings")
|
|
105
|
+
new = replace(self.settings, **values)
|
|
106
|
+
if new != self.settings:
|
|
107
|
+
self.settings = new
|
|
108
|
+
self._fallback = 0
|
|
109
|
+
self._restart.set()
|
|
110
|
+
self.notify()
|
|
111
|
+
|
|
112
|
+
def status(self):
|
|
113
|
+
now = time.monotonic()
|
|
114
|
+
start, frames, size = self._rate_window
|
|
115
|
+
if now - start >= 2:
|
|
116
|
+
elapsed = now - start
|
|
117
|
+
self._rates = (round((self.hub.frames_published - frames) / elapsed, 1),
|
|
118
|
+
round((self.hub.bytes_published - size) * 8 / elapsed / 1e6, 2))
|
|
119
|
+
self._rate_window = (now, self.hub.frames_published, self.hub.bytes_published)
|
|
120
|
+
return {"streaming": self.streaming, "control": self.streaming, "width": self.width,
|
|
121
|
+
"height": self.height, "fps": self._rates[0], "mbps": self._rates[1]}
|
|
122
|
+
|
|
123
|
+
# -- lifecycle ---------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async def find_device(self):
|
|
126
|
+
"""Return the adb device to use, or None; sets a helpful error otherwise."""
|
|
127
|
+
devices = await self.adb.devices()
|
|
128
|
+
if self.settings.serial:
|
|
129
|
+
devices = [d for d in devices if d.serial == self.settings.serial]
|
|
130
|
+
else:
|
|
131
|
+
devices = [d for d in devices if d.usb]
|
|
132
|
+
ready = [d for d in devices if d.state == "device"]
|
|
133
|
+
if ready:
|
|
134
|
+
return ready[0]
|
|
135
|
+
if devices:
|
|
136
|
+
self.error = device_problem(devices[0].state)
|
|
137
|
+
else:
|
|
138
|
+
self.error = "No Android device found. Connect the phone by USB and enable USB debugging."
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
async def _describe(self, serial):
|
|
142
|
+
props = await self.adb.getprops(serial, [
|
|
143
|
+
"ro.product.manufacturer", "ro.product.model", "ro.build.version.release",
|
|
144
|
+
"ro.build.version.sdk"])
|
|
145
|
+
sdk = props["ro.build.version.sdk"]
|
|
146
|
+
if sdk.isdigit() and int(sdk) < 21:
|
|
147
|
+
raise AdbError("scrcpy needs Android 5.0 (API 21) or newer")
|
|
148
|
+
return {"serial": serial, "name": f'{props["ro.product.manufacturer"]} {props["ro.product.model"]}'.strip(),
|
|
149
|
+
"os": "Android", "version": props["ro.build.version.release"], "sdk": sdk}
|
|
150
|
+
|
|
151
|
+
async def run(self):
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
dev = await self.find_device()
|
|
155
|
+
if dev is None:
|
|
156
|
+
self._forget_device()
|
|
157
|
+
await self.wait_viewer_change(2)
|
|
158
|
+
continue
|
|
159
|
+
if self.serial != dev.serial or self.device is None:
|
|
160
|
+
self.device = await self._describe(dev.serial)
|
|
161
|
+
self.serial = dev.serial
|
|
162
|
+
self.generation += 1
|
|
163
|
+
self._fallback = 0
|
|
164
|
+
self.error = None
|
|
165
|
+
self.notify()
|
|
166
|
+
if not self.viewer:
|
|
167
|
+
await self.wait_viewer_change(2)
|
|
168
|
+
continue
|
|
169
|
+
await self._stream(dev.serial)
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
raise
|
|
172
|
+
except AdbUnavailableError as exc:
|
|
173
|
+
self._forget_device()
|
|
174
|
+
self.error = str(exc)
|
|
175
|
+
except StreamEnded as exc:
|
|
176
|
+
self.error = str(exc) or None
|
|
177
|
+
except (AdbError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError) as exc:
|
|
178
|
+
self.error = f"Android stream stopped ({type(exc).__name__}: {exc}); retrying"
|
|
179
|
+
log.warning(self.error)
|
|
180
|
+
self.notify()
|
|
181
|
+
await asyncio.sleep(1)
|
|
182
|
+
|
|
183
|
+
def _forget_device(self):
|
|
184
|
+
if self.device is not None:
|
|
185
|
+
self.device = None
|
|
186
|
+
self.serial = None
|
|
187
|
+
self.generation += 1
|
|
188
|
+
self.hub.clear()
|
|
189
|
+
self.notify()
|
|
190
|
+
|
|
191
|
+
def _max_size(self):
|
|
192
|
+
if self._fallback:
|
|
193
|
+
size = FALLBACK_SIZES[min(self._fallback, len(FALLBACK_SIZES)) - 1]
|
|
194
|
+
return size if self.settings.max_size == 0 else min(size, self.settings.max_size)
|
|
195
|
+
return self.settings.max_size
|
|
196
|
+
|
|
197
|
+
async def _stream(self, serial):
|
|
198
|
+
self._restart.clear() # before reading settings: a later Apply still restarts
|
|
199
|
+
jar = await asyncio.to_thread(scrcpy.ensure_server, self.settings.server_path)
|
|
200
|
+
await self.adb.push(serial, await asyncio.to_thread(jar.read_bytes), scrcpy.REMOTE_PATH)
|
|
201
|
+
scid = secrets.randbits(31)
|
|
202
|
+
command = scrcpy.server_command(scid, self._max_size(), self.settings.max_fps,
|
|
203
|
+
int(self.settings.bit_rate_mbps * 1_000_000))
|
|
204
|
+
socket_name = f"localabstract:scrcpy_{scid:08x}"
|
|
205
|
+
writers = []
|
|
206
|
+
got_frame = False
|
|
207
|
+
try:
|
|
208
|
+
shell_reader, shell_writer = await self.adb.open_stream(serial, f"shell:{command}")
|
|
209
|
+
writers.append(shell_writer)
|
|
210
|
+
video, video_writer = await self._connect_video(serial, socket_name, shell_reader)
|
|
211
|
+
writers.append(video_writer)
|
|
212
|
+
control_reader, control_writer = await self.adb.open_stream(serial, socket_name)
|
|
213
|
+
writers.append(control_writer)
|
|
214
|
+
await asyncio.wait_for(video.readexactly(scrcpy.DEVICE_NAME_LENGTH), 5)
|
|
215
|
+
codec = int.from_bytes(await asyncio.wait_for(video.readexactly(4), 10), "big")
|
|
216
|
+
self._last_viewer = time.monotonic()
|
|
217
|
+
if codec == scrcpy.CODEC_ERROR:
|
|
218
|
+
raise StreamEnded("The phone's video encoder could not be configured.")
|
|
219
|
+
if codec != scrcpy.CODEC_H264:
|
|
220
|
+
raise StreamEnded("The phone disabled the video stream (is the screen capture blocked?).")
|
|
221
|
+
self.control = asyncio.Queue(CONTROL_QUEUE_LIMIT)
|
|
222
|
+
self.streaming = True
|
|
223
|
+
self.error = None
|
|
224
|
+
self.notify()
|
|
225
|
+
frame_seen = asyncio.Event()
|
|
226
|
+
tasks = [
|
|
227
|
+
asyncio.create_task(self._read_video(video, frame_seen)),
|
|
228
|
+
asyncio.create_task(self._write_control(control_writer)),
|
|
229
|
+
asyncio.create_task(self._drain(control_reader)),
|
|
230
|
+
asyncio.create_task(self._drain(shell_reader, log_lines=True)),
|
|
231
|
+
asyncio.create_task(self._watch_viewer()),
|
|
232
|
+
asyncio.create_task(self._restart.wait()),
|
|
233
|
+
]
|
|
234
|
+
try:
|
|
235
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
236
|
+
finally:
|
|
237
|
+
got_frame = frame_seen.is_set()
|
|
238
|
+
# Lift held fingers while the control writer can still deliver them.
|
|
239
|
+
await self._release_pointers()
|
|
240
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
241
|
+
await asyncio.wait_for(self._control_drained(), 0.5)
|
|
242
|
+
for task in tasks:
|
|
243
|
+
task.cancel()
|
|
244
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
245
|
+
for task in done:
|
|
246
|
+
if not task.cancelled() and task.exception() is not None:
|
|
247
|
+
raise task.exception()
|
|
248
|
+
if not got_frame and not self._restart.is_set() and self.viewer:
|
|
249
|
+
raise StreamEnded("The scrcpy server stopped before sending video.")
|
|
250
|
+
except (StreamEnded, asyncio.IncompleteReadError, asyncio.TimeoutError):
|
|
251
|
+
if not got_frame and self._fallback < len(FALLBACK_SIZES):
|
|
252
|
+
self._fallback += 1
|
|
253
|
+
log.warning("No video at this size; retrying at max_size=%s", self._max_size())
|
|
254
|
+
raise
|
|
255
|
+
finally:
|
|
256
|
+
await self._release_pointers()
|
|
257
|
+
self.streaming = False
|
|
258
|
+
self.control = None
|
|
259
|
+
self.width = self.height = 0
|
|
260
|
+
self._config = b""
|
|
261
|
+
self.hub.clear()
|
|
262
|
+
for writer in writers:
|
|
263
|
+
with contextlib.suppress(Exception):
|
|
264
|
+
writer.close()
|
|
265
|
+
self.notify()
|
|
266
|
+
|
|
267
|
+
async def _connect_video(self, serial, socket_name, shell_reader):
|
|
268
|
+
"""The server needs a moment to listen; retry until the dummy byte arrives."""
|
|
269
|
+
deadline = time.monotonic() + 10
|
|
270
|
+
while True:
|
|
271
|
+
try:
|
|
272
|
+
reader, writer = await self.adb.open_stream(serial, socket_name)
|
|
273
|
+
except AdbError:
|
|
274
|
+
reader = writer = None
|
|
275
|
+
if reader is not None:
|
|
276
|
+
try:
|
|
277
|
+
dummy = await asyncio.wait_for(reader.readexactly(1), 2)
|
|
278
|
+
if dummy == b"\x00":
|
|
279
|
+
return reader, writer
|
|
280
|
+
except (asyncio.IncompleteReadError, asyncio.TimeoutError, OSError):
|
|
281
|
+
pass
|
|
282
|
+
writer.close()
|
|
283
|
+
if shell_reader.at_eof():
|
|
284
|
+
raise StreamEnded("The scrcpy server exited during startup.")
|
|
285
|
+
if time.monotonic() > deadline:
|
|
286
|
+
raise StreamEnded("Timed out connecting to the scrcpy server on the phone.")
|
|
287
|
+
await asyncio.sleep(0.1)
|
|
288
|
+
|
|
289
|
+
async def _read_video(self, reader, frame_seen):
|
|
290
|
+
while True:
|
|
291
|
+
header = await reader.readexactly(scrcpy.HEADER_LENGTH)
|
|
292
|
+
parsed = scrcpy.parse_header(header)
|
|
293
|
+
if parsed[0] == "session":
|
|
294
|
+
_, self.width, self.height = parsed
|
|
295
|
+
self.notify()
|
|
296
|
+
continue
|
|
297
|
+
_, config, key, pts, size = parsed
|
|
298
|
+
if size > scrcpy.MAX_PACKET_SIZE:
|
|
299
|
+
raise StreamEnded("Oversized video packet")
|
|
300
|
+
data = await reader.readexactly(size)
|
|
301
|
+
if config:
|
|
302
|
+
self._config = data
|
|
303
|
+
self.hub.set_config({
|
|
304
|
+
"codec": "h264", "codec_string": scrcpy.h264_codec_string(data) or "avc1.42e01f",
|
|
305
|
+
"width": self.width, "height": self.height, "format": "annexb",
|
|
306
|
+
})
|
|
307
|
+
continue
|
|
308
|
+
if key:
|
|
309
|
+
self._last_key = time.monotonic()
|
|
310
|
+
if key and self._config:
|
|
311
|
+
data = self._config + data # parameter sets inline: any key frame is a clean entry point
|
|
312
|
+
frame_seen.set()
|
|
313
|
+
self._fallback = 0
|
|
314
|
+
self.hub.publish_video(data, key, pts)
|
|
315
|
+
|
|
316
|
+
async def _write_control(self, writer):
|
|
317
|
+
while True:
|
|
318
|
+
message = await self.control.get()
|
|
319
|
+
writer.write(message)
|
|
320
|
+
await asyncio.wait_for(writer.drain(), 5)
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
async def _drain(reader, log_lines=False):
|
|
324
|
+
while True:
|
|
325
|
+
data = await reader.read(4096)
|
|
326
|
+
if not data:
|
|
327
|
+
raise StreamEnded("The scrcpy server closed the connection.")
|
|
328
|
+
if log_lines:
|
|
329
|
+
for line in data.decode("utf-8", "replace").splitlines():
|
|
330
|
+
if line.strip():
|
|
331
|
+
log.info("scrcpy-server: %s", line.strip()[:200])
|
|
332
|
+
|
|
333
|
+
async def _watch_viewer(self):
|
|
334
|
+
while True:
|
|
335
|
+
if self.viewer:
|
|
336
|
+
self._last_viewer = time.monotonic()
|
|
337
|
+
elif time.monotonic() - self._last_viewer > VIEWER_GRACE_SECONDS:
|
|
338
|
+
return
|
|
339
|
+
await self.wait_viewer_change(1)
|
|
340
|
+
|
|
341
|
+
# -- input -------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def _send(self, message):
|
|
344
|
+
if self.control is None:
|
|
345
|
+
return {"error": "Not connected to the phone"}
|
|
346
|
+
try:
|
|
347
|
+
self.control.put_nowait(message)
|
|
348
|
+
except asyncio.QueueFull:
|
|
349
|
+
return {"error": "Control channel busy"}
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
async def _control_drained(self):
|
|
353
|
+
while self.control is not None and not self.control.empty():
|
|
354
|
+
await asyncio.sleep(0.02)
|
|
355
|
+
|
|
356
|
+
def _request_keyframe(self):
|
|
357
|
+
# RESET_VIDEO restarts the phone's encoder. Key frames already arrive every
|
|
358
|
+
# 2 s (i-frame-interval), so only reset when they have stopped coming.
|
|
359
|
+
if time.monotonic() - self._last_key < 2.5:
|
|
360
|
+
return
|
|
361
|
+
if self.control is not None:
|
|
362
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
363
|
+
self.control.put_nowait(scrcpy.simple_command("reset_video"))
|
|
364
|
+
|
|
365
|
+
def _point(self, x, y):
|
|
366
|
+
w, h = self.width, self.height
|
|
367
|
+
return round(x * (w - 1) / COORD_MAX), round(y * (h - 1) / COORD_MAX), w, h
|
|
368
|
+
|
|
369
|
+
async def _release_pointers(self):
|
|
370
|
+
if self.control is not None and self.width:
|
|
371
|
+
for pid, (x, y) in list(self.pointers.items()):
|
|
372
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
373
|
+
self.control.put_nowait(scrcpy.inject_touch(scrcpy.ACTION_UP, x, y, self.width, self.height, 0, pid))
|
|
374
|
+
self.pointers.clear()
|
|
375
|
+
|
|
376
|
+
async def set_viewer(self, present):
|
|
377
|
+
if not present:
|
|
378
|
+
await self._release_pointers()
|
|
379
|
+
await super().set_viewer(present)
|
|
380
|
+
|
|
381
|
+
async def handle_input(self, event):
|
|
382
|
+
if self.control is None or not self.width:
|
|
383
|
+
return {"error": "Video not started yet"}
|
|
384
|
+
kind = event["type"]
|
|
385
|
+
if kind == "touch":
|
|
386
|
+
x, y, w, h = self._point(event["x"], event["y"])
|
|
387
|
+
pid = event["id"]
|
|
388
|
+
phase = event["phase"]
|
|
389
|
+
if phase == "down":
|
|
390
|
+
action, pressure = scrcpy.ACTION_DOWN, 1.0
|
|
391
|
+
self.pointers[pid] = (x, y)
|
|
392
|
+
elif phase == "move":
|
|
393
|
+
if pid not in self.pointers:
|
|
394
|
+
return None
|
|
395
|
+
action, pressure = scrcpy.ACTION_MOVE, 1.0
|
|
396
|
+
self.pointers[pid] = (x, y)
|
|
397
|
+
else:
|
|
398
|
+
if self.pointers.pop(pid, None) is None:
|
|
399
|
+
return None
|
|
400
|
+
action, pressure = scrcpy.ACTION_UP, 0.0
|
|
401
|
+
return self._send(scrcpy.inject_touch(action, x, y, w, h, pressure, pid))
|
|
402
|
+
if kind == "scroll":
|
|
403
|
+
x, y, w, h = self._point(event["x"], event["y"])
|
|
404
|
+
return self._send(scrcpy.inject_scroll(x, y, w, h, event["dx"], event["dy"]))
|
|
405
|
+
if kind == "key":
|
|
406
|
+
return self._key(event["key"], event["phase"])
|
|
407
|
+
if kind == "text":
|
|
408
|
+
text = event["text"]
|
|
409
|
+
if text.isascii() and len(text.encode()) <= scrcpy.INJECT_TEXT_MAX_LENGTH:
|
|
410
|
+
return self._send(scrcpy.inject_text(text))
|
|
411
|
+
# Non-ASCII characters have no key events; paste them via the clipboard.
|
|
412
|
+
return self._send(scrcpy.set_clipboard(text, paste=True))
|
|
413
|
+
if kind == "command":
|
|
414
|
+
name = event["name"]
|
|
415
|
+
if name in {"screen_off", "screen_on"}:
|
|
416
|
+
return self._send(scrcpy.set_display_power(name == "screen_on"))
|
|
417
|
+
return self._send(scrcpy.simple_command(name))
|
|
418
|
+
return {"error": "Unsupported event"}
|
|
419
|
+
|
|
420
|
+
def _key(self, key, phase):
|
|
421
|
+
actions = {"press": (scrcpy.ACTION_DOWN, scrcpy.ACTION_UP), "down": (scrcpy.ACTION_DOWN,),
|
|
422
|
+
"up": (scrcpy.ACTION_UP,)}[phase]
|
|
423
|
+
for action in actions:
|
|
424
|
+
if key == "back":
|
|
425
|
+
message = scrcpy.back_or_screen_on(action)
|
|
426
|
+
else:
|
|
427
|
+
code = 219 if key == "siri" else scrcpy.KEYCODES[key] # KEYCODE_ASSIST
|
|
428
|
+
message = scrcpy.inject_keycode(action, code)
|
|
429
|
+
error = self._send(message)
|
|
430
|
+
if error:
|
|
431
|
+
return error
|
|
432
|
+
return None
|