tryorchard-sdk 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ """Orchard SDK — a Python client for the Orchard mobile-sandbox control plane.
2
+
3
+ Quickstart::
4
+
5
+ from orchard_sdk import Orchard
6
+
7
+ orchard = Orchard.from_env() # ORCHARD_API_URL + ORCHARD_API_KEY
8
+ for s in orchard.sandboxes.list()["instances"]:
9
+ print(s["name"], s["state"])
10
+
11
+ sb = orchard.sandbox("my-pixel") # by uuid / uuid-prefix / name / tag
12
+ sb.tag("payments", "#22c55e")
13
+ with sb.logs() as stream:
14
+ for line in stream:
15
+ print(line)
16
+
17
+ See https://docs.orchard.airoverflow.com for guides and the full API reference.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from ._bridge import LocalBridge
22
+ from ._version import __version__
23
+ from .client import Orchard
24
+ from .config import Config, from_env
25
+ from .errors import ApiError, ChannelClosedError, OrchardError, SelectorError
26
+
27
+ __all__ = [
28
+ "Orchard",
29
+ "Config",
30
+ "from_env",
31
+ "OrchardError",
32
+ "ApiError",
33
+ "SelectorError",
34
+ "ChannelClosedError",
35
+ "LocalBridge",
36
+ "__version__",
37
+ ]
orchard_sdk/_bridge.py ADDED
@@ -0,0 +1,121 @@
1
+ """A local TCP listener bridged to a device WebSocket channel (adb, vnc, screen).
2
+
3
+ This lets ordinary local tools reach a sandbox as if it were plugged in: the
4
+ bridge opens a port and, for each TCP connection, opens a fresh channel to the
5
+ device and pumps bytes both ways. So ``adb connect 127.0.0.1:<port>`` talks to
6
+ the sandbox's adb, and a VNC viewer to its screen.
7
+
8
+ It runs its own asyncio loop on a daemon thread, like :class:`SyncChannel`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import threading
14
+ from typing import Any
15
+
16
+ from . import _ws
17
+ from .config import Config
18
+
19
+
20
+ class LocalBridge:
21
+ """A local ``host:port`` listener forwarding to a device channel. Start it
22
+ (or use it as a context manager); read :attr:`port`; :meth:`close` when done.
23
+
24
+ >>> with sandbox.adb_bridge() as b:
25
+ ... os.system(f"adb connect 127.0.0.1:{b.port}")
26
+ """
27
+
28
+ def __init__(self, cfg: Config, uuid: str, suffix: str, host: str = "127.0.0.1",
29
+ port: int = 0, **params: Any):
30
+ self._url = _ws.ws_url(cfg, uuid, suffix, **params)
31
+ self.host = host
32
+ self._req_port = port
33
+ #: The bound local port (set once started; the OS picks one if port was 0).
34
+ self.port = 0
35
+ self._loop: asyncio.AbstractEventLoop | None = None
36
+ self._thread: threading.Thread | None = None
37
+ self._server: asyncio.AbstractServer | None = None
38
+ self._ready = threading.Event()
39
+ self._err: BaseException | None = None
40
+
41
+ def start(self) -> "LocalBridge":
42
+ """Open the listener and return self once it is bound."""
43
+ self._thread = threading.Thread(target=self._run, daemon=True)
44
+ self._thread.start()
45
+ if not self._ready.wait(timeout=15):
46
+ raise RuntimeError("bridge did not start within 15s")
47
+ if self._err is not None:
48
+ raise self._err
49
+ return self
50
+
51
+ def __enter__(self) -> "LocalBridge":
52
+ return self.start()
53
+
54
+ def __exit__(self, *exc: object) -> None:
55
+ self.close()
56
+
57
+ def _run(self) -> None:
58
+ self._loop = asyncio.new_event_loop()
59
+ asyncio.set_event_loop(self._loop)
60
+ try:
61
+ self._loop.run_until_complete(self._serve())
62
+ except BaseException as e: # surfaced to start() via _ready/_err
63
+ self._err = e
64
+ self._ready.set()
65
+ finally:
66
+ self._loop.close()
67
+
68
+ async def _serve(self) -> None:
69
+ self._server = await asyncio.start_server(self._handle, self.host, self._req_port)
70
+ self.port = self._server.sockets[0].getsockname()[1]
71
+ self._ready.set()
72
+ async with self._server:
73
+ await self._server.serve_forever()
74
+
75
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
76
+ try:
77
+ async with _ws.connect(self._url) as ws:
78
+ await self._pump(ws, reader, writer)
79
+ except Exception:
80
+ with _suppress():
81
+ writer.close()
82
+
83
+ @staticmethod
84
+ async def _pump(ws: Any, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
85
+ async def to_ws() -> None:
86
+ try:
87
+ while chunk := await reader.read(65536):
88
+ await ws.send(chunk)
89
+ except Exception:
90
+ pass
91
+ finally:
92
+ with _suppress():
93
+ await ws.close()
94
+
95
+ async def to_local() -> None:
96
+ try:
97
+ async for msg in ws:
98
+ writer.write(msg if isinstance(msg, (bytes, bytearray)) else msg.encode())
99
+ await writer.drain()
100
+ except Exception:
101
+ pass
102
+ finally:
103
+ with _suppress():
104
+ writer.close()
105
+
106
+ await asyncio.gather(to_ws(), to_local(), return_exceptions=True)
107
+
108
+ def close(self) -> None:
109
+ """Stop the listener and join the worker thread."""
110
+ if self._loop is not None and self._loop.is_running():
111
+ self._loop.call_soon_threadsafe(self._loop.stop)
112
+ if self._thread is not None:
113
+ self._thread.join(timeout=5)
114
+
115
+
116
+ class _suppress:
117
+ def __enter__(self) -> None:
118
+ return None
119
+
120
+ def __exit__(self, *exc: object) -> bool:
121
+ return True # swallow any exception in the with-body
@@ -0,0 +1,191 @@
1
+ """Synchronous wrappers over the async WebSocket core.
2
+
3
+ :class:`SyncChannel` lets ordinary (non-asyncio) code consume a live channel as
4
+ a plain iterator::
5
+
6
+ with sandbox.logs() as stream:
7
+ for line in stream:
8
+ print(line)
9
+
10
+ It runs a private asyncio loop on a daemon thread, pushes decoded frames onto a
11
+ queue, and shuts the loop down cleanly when the ``with`` block exits — including
12
+ on ``break`` or Ctrl-C. :class:`Tunnel` drives the reverse-tunnel control channel.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import threading
19
+ from queue import Queue
20
+ from typing import Any, Callable, Iterator
21
+
22
+ from . import _ws
23
+ from .config import Config
24
+
25
+ _SENTINEL = object()
26
+
27
+
28
+ class SyncChannel:
29
+ """A live WebSocket channel exposed as a context-managed, synchronous iterator.
30
+
31
+ Args:
32
+ url: The fully-built channel URL (see :func:`orchard_sdk._ws.ws_url`).
33
+ decode: Per-frame decoder (text/json/bytes). Defaults to text.
34
+
35
+ The channel is lazy: the socket opens on ``__enter__`` and closes on
36
+ ``__exit__``. Iterating yields decoded frames until the stream ends or the
37
+ block is left. ``send()`` pushes data upstream (e.g. shell keystrokes).
38
+ """
39
+
40
+ def __init__(self, url: str, decode: Callable[[str | bytes], Any] = _ws.decode_text,
41
+ queue_max: int = 0, open_send: str | bytes | None = None):
42
+ self._url = url
43
+ self._decode = decode
44
+ self._open_send = open_send
45
+ self._queue: "Queue[Any]" = Queue(maxsize=queue_max)
46
+ self._loop: asyncio.AbstractEventLoop | None = None
47
+ self._thread: threading.Thread | None = None
48
+ self._ws: Any = None
49
+ self._stop = False
50
+ self._exc: BaseException | None = None
51
+
52
+ # -- lifecycle --------------------------------------------------------
53
+ def __enter__(self) -> "SyncChannel":
54
+ self._thread = threading.Thread(target=self._run, daemon=True)
55
+ self._thread.start()
56
+ return self
57
+
58
+ def __exit__(self, *exc: object) -> None:
59
+ self.close()
60
+
61
+ def _run(self) -> None:
62
+ self._loop = asyncio.new_event_loop()
63
+ asyncio.set_event_loop(self._loop)
64
+ try:
65
+ self._loop.run_until_complete(self._pump())
66
+ except BaseException as e: # surfaced to the consumer thread via the queue
67
+ self._exc = e
68
+ finally:
69
+ self._queue.put(_SENTINEL)
70
+ self._loop.close()
71
+
72
+ async def _pump(self) -> None:
73
+ async with _ws.connect(self._url) as ws:
74
+ self._ws = ws
75
+ if self._open_send is not None: # e.g. a frida script sent on connect
76
+ await ws.send(self._open_send)
77
+ try:
78
+ async for frame in ws:
79
+ if self._stop:
80
+ break
81
+ self._queue.put(self._decode(frame))
82
+ except Exception as e: # ConnectionClosed w/ error code → re-raised to consumer
83
+ import websockets
84
+ if isinstance(e, websockets.ConnectionClosed):
85
+ err = _ws._closed_err(e)
86
+ if err is not None:
87
+ raise err from None
88
+ elif not self._stop:
89
+ raise
90
+
91
+ # -- consumer API -----------------------------------------------------
92
+ def __iter__(self) -> Iterator[Any]:
93
+ while True:
94
+ item = self._queue.get()
95
+ if item is _SENTINEL:
96
+ if self._exc is not None:
97
+ raise self._exc
98
+ return
99
+ yield item
100
+
101
+ def send(self, data: str | bytes) -> None:
102
+ """Send a frame upstream (e.g. a shell keystroke or a resize JSON)."""
103
+ if self._loop is None or self._ws is None:
104
+ raise RuntimeError("channel not open")
105
+ fut = asyncio.run_coroutine_threadsafe(self._ws.send(data), self._loop)
106
+ fut.result(timeout=10)
107
+
108
+ def close(self) -> None:
109
+ """Stop the stream and join the worker thread."""
110
+ self._stop = True
111
+ if self._ws is not None and self._loop is not None and self._loop.is_running():
112
+ try:
113
+ asyncio.run_coroutine_threadsafe(self._ws.close(), self._loop).result(timeout=5)
114
+ except Exception:
115
+ pass
116
+ if self._thread is not None:
117
+ self._thread.join(timeout=5)
118
+
119
+
120
+ class Tunnel:
121
+ """The reverse-tunnel control channel: forwards guest proxy connections into a
122
+ local proxy you run (Burp/mitmproxy). Context-managed; blocks in :meth:`run`.
123
+ """
124
+
125
+ def __init__(self, cfg: Config, uuid: str, to_host: str, to_port: int):
126
+ self._cfg = cfg
127
+ self._uuid = uuid
128
+ self._to = (to_host, to_port)
129
+
130
+ def run(self, on_event: Callable[[str, str], None] | None = None) -> int:
131
+ """Hold the tunnel open until the server closes it. ``on_event(level, text)``
132
+ receives ``ready``/``open``/``warn``/``error``/``close`` notifications."""
133
+ log = on_event or (lambda level, text: None)
134
+ return asyncio.run(self._run(log))
135
+
136
+ async def _run(self, log: Callable[[str, str], None]) -> int:
137
+ tasks: set[asyncio.Task] = set()
138
+ to_host, to_port = self._to
139
+
140
+ async def data(sid: str) -> None:
141
+ try:
142
+ async with _ws.connect(_ws.ws_url(self._cfg, self._uuid, "tunnel/data", sid=sid)) as ws:
143
+ reader, writer = await asyncio.open_connection(to_host, to_port)
144
+ await _pump(ws, reader, writer)
145
+ except Exception as e:
146
+ log("warn", f"stream {sid[:8]} ended: {e}")
147
+
148
+ async with _ws.connect(_ws.ws_url(self._cfg, self._uuid, "tunnel")) as control:
149
+ async for raw in control:
150
+ try:
151
+ msg = json.loads(raw)
152
+ except Exception:
153
+ continue
154
+ t = msg.get("t")
155
+ if t == "ready":
156
+ log("ready", f"guest proxy -> {msg.get('proxy_target')}")
157
+ elif t == "open":
158
+ task = asyncio.create_task(data(msg["sid"]))
159
+ tasks.add(task)
160
+ task.add_done_callback(tasks.discard)
161
+ elif t == "warn":
162
+ log("warn", msg.get("detail", ""))
163
+ elif t == "error":
164
+ log("error", msg.get("detail", "tunnel refused"))
165
+ return 1
166
+ elif t == "close":
167
+ log("close", "closed by server")
168
+ return 0
169
+ return 0
170
+
171
+
172
+ async def _pump(ws: Any, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
173
+ async def to_ws() -> None:
174
+ try:
175
+ while chunk := await reader.read(65536):
176
+ await ws.send(chunk)
177
+ finally:
178
+ await ws.close()
179
+
180
+ async def to_local() -> None:
181
+ try:
182
+ async for msg in ws:
183
+ writer.write(msg if isinstance(msg, (bytes, bytearray)) else msg.encode())
184
+ await writer.drain()
185
+ finally:
186
+ try:
187
+ writer.close()
188
+ except Exception:
189
+ pass
190
+
191
+ await asyncio.gather(to_ws(), to_local(), return_exceptions=True)
orchard_sdk/_http.py ADDED
@@ -0,0 +1,100 @@
1
+ """The sync REST transport: one httpx client that knows Orchard's base URL,
2
+ auth header, tenancy headers, and how a server error becomes an :class:`ApiError`.
3
+
4
+ Internal — consumers use :class:`orchard_sdk.Orchard` and its resource namespaces,
5
+ not this class directly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from .config import Config
15
+ from .errors import ApiError
16
+
17
+
18
+ def _detail(r: httpx.Response) -> str:
19
+ try:
20
+ body = r.json()
21
+ except Exception:
22
+ return r.text[:300].strip()
23
+ if isinstance(body, dict):
24
+ return str(body.get("detail") or body)
25
+ return str(body)
26
+
27
+
28
+ class HttpClient:
29
+ """Thin synchronous wrapper over ``httpx.Client`` scoped to one Orchard org/workspace."""
30
+
31
+ def __init__(self, cfg: Config):
32
+ self.cfg = cfg
33
+ headers = {"Authorization": f"Bearer {cfg.token}"}
34
+ if cfg.org:
35
+ headers["X-Orchard-Org"] = cfg.org
36
+ if cfg.ws:
37
+ headers["X-Orchard-Workspace"] = cfg.ws
38
+ self._c = httpx.Client(base_url=cfg.api_base, headers=headers, timeout=cfg.timeout)
39
+
40
+ def close(self) -> None:
41
+ self._c.close()
42
+
43
+ def __enter__(self) -> "HttpClient":
44
+ return self
45
+
46
+ def __exit__(self, *exc: object) -> None:
47
+ self.close()
48
+
49
+ def _request(self, method: str, path: str, **kw: Any) -> Any:
50
+ r = self._c.request(method, path, **kw)
51
+ if r.status_code >= 400:
52
+ raise ApiError(r.status_code, _detail(r))
53
+ if r.status_code == 204 or not r.content:
54
+ return None
55
+ return r.json()
56
+
57
+ def get(self, path: str, **kw: Any) -> Any:
58
+ return self._request("GET", path, **kw)
59
+
60
+ def get_content(self, path: str, **kw: Any) -> bytes:
61
+ """A GET whose body is raw bytes (screenshots, mitm CA)."""
62
+ r = self._c.request("GET", path, **kw)
63
+ if r.status_code >= 400:
64
+ raise ApiError(r.status_code, _detail(r))
65
+ return r.content
66
+
67
+ def post(self, path: str, json: Any = None, **kw: Any) -> Any:
68
+ return self._request("POST", path, json=json, **kw)
69
+
70
+ def patch(self, path: str, json: Any = None, **kw: Any) -> Any:
71
+ return self._request("PATCH", path, json=json, **kw)
72
+
73
+ def delete(self, path: str, **kw: Any) -> Any:
74
+ return self._request("DELETE", path, **kw)
75
+
76
+ def post_files(self, path: str, files: list[tuple[str, Path]],
77
+ data: dict[str, Any] | None = None) -> Any:
78
+ """multipart POST (APK/IPA install, file upload). ``files`` is ``[(field, Path)]``."""
79
+ handles = [(field, (p.name, p.open("rb"))) for field, p in files]
80
+ try:
81
+ return self._request("POST", path, files=handles, data=data or {})
82
+ finally:
83
+ for _, (_, fh) in handles:
84
+ fh.close()
85
+
86
+ def download(self, absolute_url: str, dest: Path) -> int:
87
+ """Stream a signed-grant URL (full URL incl. ``?token=``) to a file.
88
+
89
+ The grant is itself the credential, so this request carries **no**
90
+ Authorization header. Returns the number of bytes written.
91
+ """
92
+ total = 0
93
+ with httpx.Client(timeout=300.0) as raw, raw.stream("GET", absolute_url) as r:
94
+ if r.status_code >= 400:
95
+ raise ApiError(r.status_code, _detail(r))
96
+ with dest.open("wb") as fh:
97
+ for chunk in r.iter_bytes(65536):
98
+ fh.write(chunk)
99
+ total += len(chunk)
100
+ return total
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
orchard_sdk/_ws.py ADDED
@@ -0,0 +1,92 @@
1
+ """Async WebSocket core: URL assembly + a decoded frame iterator.
2
+
3
+ A *channel* is one of Orchard's per-sandbox WebSocket streams (logs, shell,
4
+ screen, adb, vnc, mitm, …). Frames arrive as text or binary depending on the
5
+ channel; :func:`channel` decodes each frame with the caller-supplied ``decode``.
6
+ Used directly by :mod:`orchard_sdk.aio`; :mod:`orchard_sdk._channels` wraps it
7
+ for synchronous callers.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json as _json
12
+ from typing import Any, AsyncIterator, Callable
13
+ from urllib.parse import urlencode
14
+
15
+ import websockets
16
+
17
+ from .config import Config
18
+ from .errors import ChannelClosedError
19
+
20
+ # Close codes the control plane emits.
21
+ _ERR_CODES = {4401, 4403, 4404, 4502}
22
+
23
+
24
+ def _closed_err(exc: "websockets.ConnectionClosed") -> ChannelClosedError | None:
25
+ """Map a ConnectionClosed to a ChannelClosedError when the code is an Orchard
26
+ error code, else None (a normal end-of-stream). Reads the close frame
27
+ directly to avoid the deprecated ``.code``/``.reason`` shims."""
28
+ frame = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
29
+ code = frame.code if frame is not None else 1006
30
+ if code in _ERR_CODES:
31
+ return ChannelClosedError(code, (frame.reason if frame is not None else "") or "")
32
+ return None
33
+
34
+
35
+ def ws_url(cfg: Config, uuid: str, suffix: str, **extra: Any) -> str:
36
+ """Build ``wss?://host/ws/sandboxes/<uuid>/<suffix>?token=…`` with auth + extras.
37
+
38
+ ``extra`` values that are ``None`` are omitted (e.g. ``sid=None``).
39
+ """
40
+ q: dict[str, Any] = {"token": cfg.token}
41
+ if cfg.org:
42
+ q["org"] = cfg.org
43
+ if cfg.ws:
44
+ q["ws"] = cfg.ws
45
+ q.update({k: v for k, v in extra.items() if v is not None})
46
+ return f"{cfg.ws_base}/ws/sandboxes/{uuid}/{suffix}?{urlencode(q)}"
47
+
48
+
49
+ def raw_ws_url(cfg: Config, path: str, **extra: Any) -> str:
50
+ """Build a WS URL for a non-sandbox path (e.g. ``ws/jobs/…``, ``ws/remote/…``)."""
51
+ q: dict[str, Any] = {"token": cfg.token}
52
+ if cfg.org:
53
+ q["org"] = cfg.org
54
+ if cfg.ws:
55
+ q["ws"] = cfg.ws
56
+ q.update({k: v for k, v in extra.items() if v is not None})
57
+ return f"{cfg.ws_base}/{path.lstrip('/')}?{urlencode(q)}"
58
+
59
+
60
+ def connect(url: str):
61
+ """Open a client connection with the raw-transport settings binary channels need."""
62
+ return websockets.connect(url, max_size=None, open_timeout=15)
63
+
64
+
65
+ def decode_text(frame: str | bytes) -> str:
66
+ return frame if isinstance(frame, str) else frame.decode(errors="replace")
67
+
68
+
69
+ def decode_json(frame: str | bytes) -> Any:
70
+ return _json.loads(frame)
71
+
72
+
73
+ def decode_bytes(frame: str | bytes) -> bytes:
74
+ return frame if isinstance(frame, (bytes, bytearray)) else frame.encode()
75
+
76
+
77
+ async def channel(url: str, decode: Callable[[str | bytes], Any] = decode_text
78
+ ) -> AsyncIterator[Any]:
79
+ """Async-iterate decoded frames from ``url`` until the socket closes cleanly.
80
+
81
+ Raises :class:`ChannelClosedError` if the server closes with an error code
82
+ (4401/4403/4404/4502).
83
+ """
84
+ async with connect(url) as ws:
85
+ try:
86
+ async for frame in ws:
87
+ yield decode(frame)
88
+ except websockets.ConnectionClosed as exc:
89
+ err = _closed_err(exc)
90
+ if err is not None:
91
+ raise err from None
92
+ # 1000/1001/etc. are a normal end-of-stream.
@@ -0,0 +1,6 @@
1
+ """Asyncio-native WebSocket channels. REST stays synchronous on
2
+ :class:`orchard_sdk.Orchard`; this exposes the async channel iterators."""
3
+ from .._ws import channel
4
+ from .channels import AsyncChannels
5
+
6
+ __all__ = ["AsyncChannels", "channel"]
@@ -0,0 +1,57 @@
1
+ """Async WebSocket channels for asyncio callers.
2
+
3
+ The synchronous :class:`orchard_sdk.resources.sandboxes.Sandbox` channels wrap
4
+ these behind a thread. If you are already in an event loop, use these directly::
5
+
6
+ import asyncio
7
+ from orchard_sdk import from_env
8
+ from orchard_sdk.aio import AsyncChannels
9
+
10
+ async def main():
11
+ cfg = from_env()
12
+ async for line in AsyncChannels(cfg, "<uuid>").logs():
13
+ print(line)
14
+
15
+ asyncio.run(main())
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, AsyncIterator
20
+
21
+ from .. import _ws
22
+ from ..config import Config
23
+
24
+
25
+ class AsyncChannels:
26
+ """Async-iterable live channels for one sandbox uuid."""
27
+
28
+ def __init__(self, cfg: Config, uuid: str):
29
+ self._cfg = cfg
30
+ self._uuid = uuid
31
+
32
+ def _url(self, suffix: str, **extra: Any) -> str:
33
+ return _ws.ws_url(self._cfg, self._uuid, suffix, **extra)
34
+
35
+ def logs(self) -> AsyncIterator[str]:
36
+ """Async-iterate log lines."""
37
+ return _ws.channel(self._url("logs"), _ws.decode_text)
38
+
39
+ def shell(self) -> AsyncIterator[str]:
40
+ """Async-iterate shell output (read-only here; use the raw socket to send)."""
41
+ return _ws.channel(self._url("shell"), _ws.decode_text)
42
+
43
+ def screen(self, source: str = "scrcpy") -> AsyncIterator[bytes]:
44
+ """Async-iterate raw screen frames."""
45
+ return _ws.channel(self._url("screen/stream", source=source), _ws.decode_bytes)
46
+
47
+ def adb(self) -> AsyncIterator[bytes]:
48
+ """Async-iterate raw adb transport bytes."""
49
+ return _ws.channel(self._url("adb"), _ws.decode_bytes)
50
+
51
+ def vnc(self) -> AsyncIterator[bytes]:
52
+ """Async-iterate raw RFB bytes (iOS VM)."""
53
+ return _ws.channel(self._url("vnc"), _ws.decode_bytes)
54
+
55
+ def mitm_flows(self) -> AsyncIterator[Any]:
56
+ """Async-iterate captured flow summaries (decoded JSON)."""
57
+ return _ws.channel(self._url("mitm/flows"), _ws.decode_json)