pythonnative 0.38.0__py3-none-any.whl → 0.39.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.
Files changed (74) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/alerts.py +1 -1
  3. pythonnative/animated.py +1 -1
  4. pythonnative/bootstrap.py +15 -6
  5. pythonnative/bridge/__init__.py +16 -9
  6. pythonnative/bridge/codec.py +1 -1
  7. pythonnative/bridge/web.py +530 -0
  8. pythonnative/cli/pn.py +330 -331
  9. pythonnative/components/lists.py +2 -2
  10. pythonnative/devclient.py +824 -0
  11. pythonnative/devserver/__init__.py +34 -0
  12. pythonnative/devserver/server.py +712 -0
  13. pythonnative/devserver/static/bridge.js +155 -0
  14. pythonnative/devserver/static/colors.js +70 -0
  15. pythonnative/devserver/static/host.js +577 -0
  16. pythonnative/devserver/static/index.html +60 -0
  17. pythonnative/devserver/static/preview.css +415 -0
  18. pythonnative/devserver/static/renderer.js +2037 -0
  19. pythonnative/devserver/static/shell.js +343 -0
  20. pythonnative/devserver/watcher.py +267 -0
  21. pythonnative/devserver/ws.py +421 -0
  22. pythonnative/gestures.py +4 -4
  23. pythonnative/hooks.py +2 -2
  24. pythonnative/hosts/__init__.py +20 -19
  25. pythonnative/hosts/base.py +35 -44
  26. pythonnative/hosts/native.py +4 -14
  27. pythonnative/hot_reload.py +205 -280
  28. pythonnative/images.py +2 -1
  29. pythonnative/native_modules/__init__.py +6 -4
  30. pythonnative/native_modules/biometrics.py +1 -1
  31. pythonnative/native_modules/camera.py +1 -1
  32. pythonnative/native_modules/{desktop.py → fallback.py} +42 -40
  33. pythonnative/native_modules/file_system.py +1 -1
  34. pythonnative/native_modules/haptics.py +1 -1
  35. pythonnative/native_modules/linking.py +1 -1
  36. pythonnative/native_modules/notifications.py +2 -2
  37. pythonnative/native_modules/registry.py +6 -6
  38. pythonnative/native_modules/secure_store.py +1 -1
  39. pythonnative/native_modules/share.py +1 -1
  40. pythonnative/native_views/__init__.py +18 -23
  41. pythonnative/native_views/base.py +1 -1
  42. pythonnative/native_views/bridge_backend.py +3 -2
  43. pythonnative/navigation/handle.py +1 -1
  44. pythonnative/navigation/screen.py +1 -1
  45. pythonnative/platform.py +21 -20
  46. pythonnative/platform_metrics.py +1 -1
  47. pythonnative/preview.py +261 -499
  48. pythonnative/project/android.py +13 -3
  49. pythonnative/project/builder.py +29 -4
  50. pythonnative/project/doctor.py +0 -20
  51. pythonnative/project/fingerprint.py +170 -0
  52. pythonnative/project/ios.py +6 -0
  53. pythonnative/reconciler/core.py +1 -1
  54. pythonnative/runtime.py +18 -42
  55. pythonnative/sdk/__init__.py +6 -6
  56. pythonnative/sdk/_components.py +12 -12
  57. pythonnative/storage.py +2 -2
  58. pythonnative/templates/android_template/app/build.gradle +6 -0
  59. pythonnative/templates/android_template/app/src/debug/AndroidManifest.xml +7 -0
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +5 -2
  61. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +0 -3
  62. pythonnative/templates/android_template/pythonnative/src/main/java/com/pythonnative/runtime/screens/PNScreenFragment.kt +6 -41
  63. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Modules/HostModule.swift +0 -1
  64. pythonnative/templates/ios_template/PythonNativeKit/Sources/PythonNativeKit/Screens/PNViewController.swift +6 -22
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +1 -7
  66. pythonnative/utils.py +39 -31
  67. {pythonnative-0.38.0.dist-info → pythonnative-0.39.0.dist-info}/METADATA +16 -6
  68. {pythonnative-0.38.0.dist-info → pythonnative-0.39.0.dist-info}/RECORD +72 -59
  69. pythonnative/hosts/desktop.py +0 -107
  70. pythonnative/native_views/desktop.py +0 -2356
  71. {pythonnative-0.38.0.dist-info → pythonnative-0.39.0.dist-info}/WHEEL +0 -0
  72. {pythonnative-0.38.0.dist-info → pythonnative-0.39.0.dist-info}/entry_points.txt +0 -0
  73. {pythonnative-0.38.0.dist-info → pythonnative-0.39.0.dist-info}/licenses/LICENSE +0 -0
  74. {pythonnative-0.38.0.dist-info → pythonnative-0.39.0.dist-info}/top_level.txt +0 -0
pythonnative/__init__.py CHANGED
@@ -62,7 +62,7 @@ Example:
62
62
  ```
63
63
  """
64
64
 
65
- __version__ = "0.38.0"
65
+ __version__ = "0.39.0"
66
66
 
67
67
  from . import appearance, diagnostics, gestures, images, runtime, sdk
68
68
  from .alerts import Alert
pythonnative/alerts.py CHANGED
@@ -52,7 +52,7 @@ async def _present(
52
52
  "default"|"cancel"|"destructive"}`` dicts. The module resolves with
53
53
  the index the user picked, or ``-1`` if the dialog was dismissed
54
54
  without a selection. Off device the
55
- [`DesktopAlert`][pythonnative.native_modules.desktop.DesktopAlert]
55
+ [`FallbackAlert`][pythonnative.native_modules.fallback.FallbackAlert]
56
56
  implementation records the call and answers from the queue set by
57
57
  [`Alert.set_test_response`][pythonnative.alerts.Alert.set_test_response].
58
58
  """
pythonnative/animated.py CHANGED
@@ -40,7 +40,7 @@ native view the value is attached to
40
40
  callback when the animation settles, updates the
41
41
  [`AnimatedValue`][pythonnative.animated.AnimatedValue], and resolves
42
42
  any awaiting tasks.
43
- - **Declined** (desktop preview, unattached values, callable easings,
43
+ - **Declined** (unattached values, callable easings,
44
44
  values feeding Python-side listeners or derived nodes): a single
45
45
  background thread ticks the animation at ~60 Hz from Python, pushing
46
46
  each frame through ``set_animated_property``. Semantics are
pythonnative/bootstrap.py CHANGED
@@ -8,9 +8,10 @@ import pythonnative.bootstrap; pythonnative.bootstrap.start()
8
8
 
9
9
  [`start`][pythonnative.bootstrap.start] connects the two halves of the
10
10
  bridge (installing the native -> Python callback on iOS), verifies the
11
- protocol version, routes ``print()`` to the console on iOS, and warms
12
- the asyncio runtime. From then on the native runtime drives everything
13
- through ``callback("host", ...)``; see ``docs/concepts/bridge.md``.
11
+ protocol version, routes ``print()`` to the console on iOS, warms the
12
+ asyncio runtime, and, in debug builds, starts the dev client that syncs
13
+ sources from ``pn start``. From then on the native runtime drives
14
+ everything through ``callback("host", ...)``; see ``docs/concepts/bridge.md``.
14
15
  """
15
16
 
16
17
  from __future__ import annotations
@@ -29,8 +30,8 @@ def start(dev: bool = False, strict: bool = False) -> Dict[str, Any]:
29
30
  """Connect the bridge and prepare the runtime.
30
31
 
31
32
  Args:
32
- dev: Enable dev mode (RedBox, validation warnings). Debug
33
- templates pass ``True``; hot reload turns it on as well.
33
+ dev: Enable dev mode (RedBox, validation warnings, the dev
34
+ client). Debug templates pass ``True``.
34
35
  strict: Re-raise the failure after recording it. The templates
35
36
  pass ``True`` so a broken bridge surfaces as a bootstrap
36
37
  error screen with the full traceback.
@@ -59,7 +60,8 @@ def start(dev: bool = False, strict: bool = False) -> Dict[str, Any]:
59
60
  from . import bridge
60
61
 
61
62
  status_["protocol"] = bridge.handshake()
62
- if dev or os.environ.get("PN_DEV") in ("1", "true"):
63
+ dev_mode = bool(dev or os.environ.get("PN_DEV") in ("1", "true"))
64
+ if dev_mode:
63
65
  from . import diagnostics
64
66
 
65
67
  diagnostics.set_dev_mode(True)
@@ -73,6 +75,13 @@ def start(dev: bool = False, strict: bool = False) -> Dict[str, Any]:
73
75
  from .native_views import get_registry
74
76
 
75
77
  get_registry()
78
+ if dev_mode:
79
+ # Debug builds are dev clients: connect to `pn start` when
80
+ # the build (or a remembered connection) names a server.
81
+ from . import devclient
82
+
83
+ client = devclient.start_if_configured()
84
+ status_["dev_server"] = client.url if client is not None else None
76
85
  except Exception as exc:
77
86
  status_["error"] = f"{type(exc).__name__}: {exc}"
78
87
  print(f"[pn.bootstrap] start failed: {exc!r}", file=sys.stderr)
@@ -7,11 +7,13 @@ Everything that crosses into native code goes through a
7
7
  [`native_callback`][pythonnative.bridge.native_callback]. The protocol
8
8
  is documented in ``docs/concepts/bridge.md``.
9
9
 
10
- Off-device (tests, ``pn preview``) there is no transport; the desktop
11
- registry renders with Tkinter and native modules fall back to their
12
- Python implementations. Tests that want to exercise the bridge itself
13
- install a [`FakeTransport`][pythonnative.bridge.fake.FakeTransport]
14
- with [`set_transport`][pythonnative.bridge.set_transport].
10
+ Under ``pn preview`` the native side is a browser page, reached through
11
+ [`WebTransport`][pythonnative.bridge.web.WebTransport]; the preview
12
+ installs it with [`set_transport`][pythonnative.bridge.set_transport]
13
+ before any screen mounts. In headless tests there is no transport at
14
+ all: native modules fall back to their Python implementations, and
15
+ tests that want to exercise the bridge itself install a
16
+ [`FakeTransport`][pythonnative.bridge.fake.FakeTransport].
15
17
  """
16
18
 
17
19
  from __future__ import annotations
@@ -74,7 +76,7 @@ _explicit = False
74
76
 
75
77
 
76
78
  def _create_platform_transport() -> Optional[Transport]:
77
- from ..utils import IS_ANDROID, IS_IOS
79
+ from ..utils import IS_ANDROID, IS_IOS, IS_WEB
78
80
 
79
81
  if IS_IOS:
80
82
  from .ios import IOSTransport
@@ -86,6 +88,11 @@ def _create_platform_transport() -> Optional[Transport]:
86
88
  from .android import AndroidTransport
87
89
 
88
90
  return AndroidTransport()
91
+ if IS_WEB:
92
+ raise RuntimeError(
93
+ "PN_PLATFORM=web is set but no browser preview transport is installed. "
94
+ "Start the app with `pn preview` (or `pn start`), which installs the WebTransport."
95
+ )
89
96
  return None
90
97
 
91
98
 
@@ -104,7 +111,7 @@ def get_transport() -> Transport:
104
111
  if created is None:
105
112
  raise RuntimeError(
106
113
  "No native bridge is available on this platform (running off-device). "
107
- "Use `pn preview` for the desktop renderer or install a FakeTransport in tests."
114
+ "Use `pn preview` for the browser renderer or install a FakeTransport in tests."
108
115
  )
109
116
  _transport = created
110
117
  return _transport
@@ -116,9 +123,9 @@ def has_transport() -> bool:
116
123
  return True
117
124
  if _explicit:
118
125
  return False
119
- from ..utils import IS_ANDROID, IS_IOS
126
+ from ..utils import IS_ANDROID, IS_IOS, IS_WEB
120
127
 
121
- return bool(IS_IOS or IS_ANDROID)
128
+ return bool(IS_IOS or IS_ANDROID or IS_WEB)
122
129
 
123
130
 
124
131
  def set_transport(transport: Optional[Transport]) -> None:
@@ -104,7 +104,7 @@ def encode_transaction(
104
104
 
105
105
  Args:
106
106
  ops: Ordered mutations from the reconciler.
107
- prop_filter: Unused hook kept for symmetry with the desktop
107
+ prop_filter: Unused hook kept for symmetry with the test
108
108
  registry; reserved for per-type prop rewriting.
109
109
 
110
110
  Returns:
@@ -0,0 +1,530 @@
1
+ """The browser preview's half of the bridge.
2
+
3
+ ``pn preview`` renders an app in a browser tab. Rather than a second
4
+ rendering backend, the page is treated as *the native runtime*: it
5
+ receives the very same JSON transactions the Swift and Kotlin runtimes
6
+ apply, answers the same synchronous ``measure`` / ``command`` /
7
+ ``animate`` / ``call`` requests, and raises the same
8
+ ``callback(kind, tag, name, payload)`` events. The reconciler therefore
9
+ runs the on-device
10
+ [`BridgeBackend`][pythonnative.native_views.bridge_backend.BridgeBackend]
11
+ and the on-device
12
+ [`NativeScreenHost`][pythonnative.hosts.native.NativeScreenHost]
13
+ unchanged; only this transport differs, and it is about moving strings
14
+ over a WebSocket.
15
+
16
+ Threads:
17
+
18
+ - The **main thread** owns the framework: it drains
19
+ [`WebTransport.run_main_loop`][pythonnative.bridge.web.WebTransport.run_main_loop],
20
+ which is the browser's stand-in for the UIKit / Android main queue.
21
+ Every callback from the page and every asyncio pump runs there.
22
+ - The **dev server thread** owns the socket. It delivers page messages
23
+ to the transport, which either settles a waiting request (``res``)
24
+ or queues work for the main thread.
25
+
26
+ Synchronous requests (``measure`` above all) block the main thread on a
27
+ ``threading.Event`` until the page answers; the page is single-threaded
28
+ but its message handling is asynchronous, so it can answer a
29
+ ``measure`` while it is itself awaiting Python (a row bind, say).
30
+
31
+ Wire format (JSON arrays):
32
+
33
+ - Python -> page: ``["apply", ops]``, ``["measure", id, tag, w, h]``,
34
+ ``["command", id, tag, name, args]``, ``["animate", id, tag, request]``,
35
+ ``["call", id, module, method, envelope]``, ``["res", id, result]``,
36
+ ``["dev", {...}]``.
37
+ - Page -> Python: ``["res", id, result]``, ``["cb", kind, tag, name,
38
+ payload]`` (fire and forget), ``["req", id, kind, tag, name, payload]``
39
+ (Python answers with ``res``), ``["gesture", tag, phase, info]``
40
+ (pointer stream for the Python gesture arbiter), ``["dev", {...}]``.
41
+
42
+ ``payload`` / ``args`` / ``request`` / ``result`` are JSON *strings*,
43
+ exactly the text the native protocol carries, so both sides reuse their
44
+ existing codecs. Modules the page implements (``Host``, ``Alert``,
45
+ ``Clipboard``, ...) are called there; every other native module falls
46
+ back to the Python implementations in
47
+ ``pythonnative.native_modules.fallback``.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import itertools
53
+ import json
54
+ import queue
55
+ import sys
56
+ import threading
57
+ import time
58
+ import traceback
59
+ from typing import Any, Callable, Dict, List, Optional, Tuple
60
+
61
+ from . import PROTOCOL_VERSION, codec
62
+
63
+ __all__ = ["BROWSER_MODULES", "WebTransport"]
64
+
65
+ BROWSER_MODULES = frozenset(
66
+ {"Host", "Alert", "Clipboard", "Linking", "Share", "Haptics", "NetInfo", "AppState", "Device"}
67
+ )
68
+ """Native modules the preview page implements; the rest use Python fallbacks."""
69
+
70
+ REQUEST_TIMEOUT_S = 15.0
71
+ """How long a synchronous request waits for the page before giving up."""
72
+
73
+ Callback = Callable[[str, int, str, str], Optional[str]]
74
+
75
+
76
+ class _Waiter:
77
+ __slots__ = ("event", "result", "failed")
78
+
79
+ def __init__(self) -> None:
80
+ self.event = threading.Event()
81
+ self.result: Any = None
82
+ self.failed = False
83
+
84
+
85
+ class WebTransport:
86
+ """Bridge transport whose native side is a browser page.
87
+
88
+ Install it with ``pythonnative.bridge.set_transport`` and hand it to
89
+ ``DevServer.set_preview_channel``; the page does the rest.
90
+
91
+ Args:
92
+ log: Where diagnostics go (defaults to stderr).
93
+ """
94
+
95
+ name = "web"
96
+
97
+ def __init__(self, *, log: Optional[Callable[[str], None]] = None) -> None:
98
+ self._log = log or (lambda line: print(line, file=sys.stderr, flush=True))
99
+ self._callback: Optional[Callback] = None
100
+ self._peer: Any = None
101
+ self._peer_lock = threading.Lock()
102
+ self._ids = itertools.count(1)
103
+ self._waiters: Dict[int, _Waiter] = {}
104
+ self._waiters_lock = threading.Lock()
105
+ self._main: "queue.Queue[Callable[[], None]]" = queue.Queue()
106
+ self._main_thread: Optional[threading.Thread] = None
107
+ self._stop = threading.Event()
108
+ self._gestures: Dict[int, Tuple[str, Any]] = {}
109
+ self._gesture_timer: Optional[Any] = None
110
+ self._warned_no_peer = False
111
+ self._python_modules: Dict[str, Any] = {}
112
+ self.on_dev_message: Optional[Callable[[Dict[str, Any]], None]] = None
113
+ """Hook for ``["dev", {...}]`` messages from the page (runs on the main thread)."""
114
+ self.on_peer_changed: Optional[Callable[[bool], None]] = None
115
+ """Called on the main thread with ``True`` on connect and ``False`` on disconnect."""
116
+
117
+ # ------------------------------------------------------------------
118
+ # Transport protocol
119
+ # ------------------------------------------------------------------
120
+
121
+ def protocol_version(self) -> int:
122
+ """The page speaks whatever this package speaks; they ship together."""
123
+ return PROTOCOL_VERSION
124
+
125
+ def set_callback(self, callback: Callback) -> None:
126
+ """Install the native -> Python entry point (``bridge.native_callback``)."""
127
+ self._callback = callback
128
+
129
+ def apply(self, transaction_json: str) -> None:
130
+ """Forward one commit to the page (fire and forget)."""
131
+ self._send('["apply",' + transaction_json + "]")
132
+
133
+ def measure(self, tag: int, max_width: float, max_height: float) -> Tuple[float, float]:
134
+ """Ask the page for the intrinsic size of ``tag``."""
135
+ result = self._request(["measure", None, int(tag), _finite(max_width), _finite(max_height)])
136
+ if isinstance(result, (list, tuple)) and len(result) >= 2:
137
+ try:
138
+ return (float(result[0]), float(result[1]))
139
+ except (TypeError, ValueError):
140
+ return (0.0, 0.0)
141
+ return (0.0, 0.0)
142
+
143
+ def command(self, tag: int, name: str, args_json: str) -> Optional[str]:
144
+ """Run an imperative command on one view; returns its JSON result or ``None``."""
145
+ result = self._request(["command", None, int(tag), name, args_json])
146
+ return _as_json_text(result)
147
+
148
+ def animate(self, tag: int, request_json: str) -> Optional[str]:
149
+ """Handle an animation request (``set`` / ``start`` / ``cancel``) for one view."""
150
+ result = self._request(["animate", None, int(tag), request_json])
151
+ return _as_json_text(result)
152
+
153
+ def call(self, module: str, method: str, args_json: str) -> Optional[str]:
154
+ """Call a native module: ``Host.post`` locally, page modules over the wire, others in Python."""
155
+ if module == "Host" and method == "post":
156
+ self.post_to_main(self._pump)
157
+ return codec.dumps({"ok": True, "value": None})
158
+ if module in BROWSER_MODULES:
159
+ result = self._request(["call", None, module, method, args_json])
160
+ return _as_json_text(result)
161
+ return self._call_python_module(module, method, args_json)
162
+
163
+ # ------------------------------------------------------------------
164
+ # Main thread
165
+ # ------------------------------------------------------------------
166
+
167
+ def post_to_main(self, fn: Callable[[], None]) -> None:
168
+ """Queue ``fn`` for the main loop (never runs inline)."""
169
+ self._main.put(fn)
170
+
171
+ def run_main_loop(self, *, until: Optional[Callable[[], bool]] = None) -> None:
172
+ """Drain main-thread work until ``stop`` is called (or ``until`` holds).
173
+
174
+ This is the preview's event loop: the browser's stand-in for the
175
+ platform main queue. Call it from the thread that should own the
176
+ framework (the process main thread under ``pn preview``).
177
+ """
178
+ self._main_thread = threading.current_thread()
179
+ self._stop.clear()
180
+ while not self._stop.is_set():
181
+ if until is not None and until():
182
+ return
183
+ try:
184
+ job = self._main.get(timeout=0.1)
185
+ except queue.Empty:
186
+ continue
187
+ self._run_job(job)
188
+
189
+ def drain_main(self, timeout: float = 0.0) -> int:
190
+ """Run queued main-thread work inline (tests); returns how many jobs ran."""
191
+ self._main_thread = threading.current_thread()
192
+ deadline = time.monotonic() + timeout
193
+ ran = 0
194
+ while True:
195
+ try:
196
+ job = self._main.get_nowait()
197
+ except queue.Empty:
198
+ if timeout <= 0 or time.monotonic() >= deadline:
199
+ return ran
200
+ time.sleep(0.002)
201
+ continue
202
+ self._run_job(job)
203
+ ran += 1
204
+
205
+ def stop(self) -> None:
206
+ """Make ``run_main_loop`` return."""
207
+ self._stop.set()
208
+
209
+ def _run_job(self, job: Callable[[], None]) -> None:
210
+ try:
211
+ job()
212
+ except Exception:
213
+ self._log("[pn preview] main-thread job raised:")
214
+ self._log(traceback.format_exc())
215
+
216
+ def _pump(self) -> None:
217
+ if self._callback is not None:
218
+ self._callback("pump", 0, "", "")
219
+
220
+ # ------------------------------------------------------------------
221
+ # PreviewChannel (called on the dev server thread)
222
+ # ------------------------------------------------------------------
223
+
224
+ def on_preview_connected(self, peer: Any, info: Dict[str, Any]) -> None:
225
+ """A page connected; it becomes the native side."""
226
+ del info
227
+ with self._peer_lock:
228
+ self._peer = peer
229
+ self._warned_no_peer = False
230
+ self.post_to_main(lambda: self._peer_changed(True))
231
+
232
+ def on_preview_disconnected(self, peer: Any) -> None:
233
+ """The page went away: fail waiting requests and tear down its screens."""
234
+ with self._peer_lock:
235
+ if self._peer is not peer:
236
+ return
237
+ self._peer = None
238
+ self._fail_all_waiters()
239
+ self.post_to_main(lambda: self._peer_changed(False))
240
+
241
+ def on_preview_message(self, peer: Any, text: str) -> None:
242
+ """Route one frame from the page."""
243
+ with self._peer_lock:
244
+ if peer is not self._peer:
245
+ return
246
+ try:
247
+ message = json.loads(text)
248
+ except ValueError:
249
+ self._log(f"[pn preview] dropped malformed message: {text[:120]!r}")
250
+ return
251
+ if not isinstance(message, list) or not message:
252
+ return
253
+ kind = message[0]
254
+ if kind == "res":
255
+ self._settle(message)
256
+ return
257
+ if kind == "cb":
258
+ self.post_to_main(lambda: self._deliver_fire_and_forget(message))
259
+ return
260
+ if kind == "req":
261
+ self.post_to_main(lambda: self._deliver_request(peer, message))
262
+ return
263
+ if kind == "gesture":
264
+ self.post_to_main(lambda: self._deliver_gesture(message))
265
+ return
266
+ if kind == "dev":
267
+ payload = message[1] if len(message) > 1 and isinstance(message[1], dict) else {}
268
+ self.post_to_main(lambda: self._deliver_dev(payload))
269
+ return
270
+ self._log(f"[pn preview] unknown message kind {kind!r}")
271
+
272
+ # ------------------------------------------------------------------
273
+ # Sending
274
+ # ------------------------------------------------------------------
275
+
276
+ @property
277
+ def connected(self) -> bool:
278
+ """Whether a page is attached."""
279
+ with self._peer_lock:
280
+ return self._peer is not None
281
+
282
+ def send_dev(self, payload: Dict[str, Any]) -> None:
283
+ """Send a ``["dev", {...}]`` message to the page (logs, reload status, ...)."""
284
+ self._send(codec.dumps(["dev", codec.to_jsonable(payload)]))
285
+
286
+ def _send(self, text: str) -> bool:
287
+ with self._peer_lock:
288
+ peer = self._peer
289
+ if peer is None:
290
+ return False
291
+ try:
292
+ peer.send(text)
293
+ except Exception as exc:
294
+ self._log(f"[pn preview] send failed: {exc!r}")
295
+ return False
296
+ return True
297
+
298
+ def _request(self, message: List[Any]) -> Any:
299
+ """Send ``message`` (slot 1 receives the id) and block for the ``res``."""
300
+ with self._peer_lock:
301
+ peer = self._peer
302
+ if peer is None:
303
+ if not self._warned_no_peer:
304
+ self._warned_no_peer = True
305
+ self._log("[pn preview] request made with no browser attached; answering with defaults")
306
+ return None
307
+ request_id = next(self._ids)
308
+ message[1] = request_id
309
+ waiter = _Waiter()
310
+ with self._waiters_lock:
311
+ self._waiters[request_id] = waiter
312
+ try:
313
+ peer.send(codec.dumps(message))
314
+ except Exception as exc:
315
+ with self._waiters_lock:
316
+ self._waiters.pop(request_id, None)
317
+ self._log(f"[pn preview] request send failed: {exc!r}")
318
+ return None
319
+ if not waiter.event.wait(REQUEST_TIMEOUT_S):
320
+ with self._waiters_lock:
321
+ self._waiters.pop(request_id, None)
322
+ self._log(f"[pn preview] {message[0]} request timed out after {REQUEST_TIMEOUT_S:g}s")
323
+ return None
324
+ if waiter.failed:
325
+ return None
326
+ return waiter.result
327
+
328
+ def _settle(self, message: List[Any]) -> None:
329
+ if len(message) < 2:
330
+ return
331
+ try:
332
+ request_id = int(message[1])
333
+ except (TypeError, ValueError):
334
+ return
335
+ with self._waiters_lock:
336
+ waiter = self._waiters.pop(request_id, None)
337
+ if waiter is None:
338
+ return
339
+ waiter.result = message[2] if len(message) > 2 else None
340
+ waiter.event.set()
341
+
342
+ def _fail_all_waiters(self) -> None:
343
+ with self._waiters_lock:
344
+ waiters = list(self._waiters.values())
345
+ self._waiters.clear()
346
+ for waiter in waiters:
347
+ waiter.failed = True
348
+ waiter.event.set()
349
+
350
+ # ------------------------------------------------------------------
351
+ # Inbound (main thread)
352
+ # ------------------------------------------------------------------
353
+
354
+ def _deliver_callback(self, message: List[Any]) -> Optional[str]:
355
+ callback = self._callback
356
+ if callback is None or len(message) < 5:
357
+ return None
358
+ _, kind, tag, name, payload = message[:5]
359
+ return callback(str(kind), int(tag or 0), str(name), _payload_text(payload))
360
+
361
+ def _deliver_fire_and_forget(self, message: List[Any]) -> None:
362
+ self._deliver_callback(message)
363
+
364
+ def _deliver_request(self, peer: Any, message: List[Any]) -> None:
365
+ if len(message) < 6:
366
+ return
367
+ request_id = message[1]
368
+ result = self._deliver_callback(["cb", *message[2:6]])
369
+ try:
370
+ peer.send(codec.dumps(["res", request_id, result]))
371
+ except Exception as exc:
372
+ self._log(f"[pn preview] reply failed: {exc!r}")
373
+
374
+ def _deliver_dev(self, payload: Dict[str, Any]) -> None:
375
+ hook = self.on_dev_message
376
+ if hook is not None:
377
+ hook(payload)
378
+
379
+ def _peer_changed(self, connected: bool) -> None:
380
+ if not connected:
381
+ self._gestures.clear()
382
+ hook = self.on_peer_changed
383
+ if hook is not None:
384
+ hook(connected)
385
+
386
+ # -- gestures --------------------------------------------------------
387
+
388
+ def _deliver_gesture(self, message: List[Any]) -> None:
389
+ """Feed a pointer event to the tag's arbiter (built from the specs the page sends)."""
390
+ if len(message) < 4:
391
+ return
392
+ _, tag, phase, info = message[:4]
393
+ if not isinstance(info, dict):
394
+ return
395
+ tag = int(tag)
396
+ arbiter = self._arbiter_for(tag, info.get("specs"))
397
+ if arbiter is None:
398
+ return
399
+ t = float(info.get("t", time.monotonic()))
400
+ pointer = int(info.get("id", 0))
401
+ x = float(info.get("x", 0.0))
402
+ y = float(info.get("y", 0.0))
403
+ if phase == "down":
404
+ arbiter.pointer_down(pointer, x, y, t)
405
+ elif phase == "move":
406
+ arbiter.pointer_move(pointer, x, y, t)
407
+ elif phase == "up":
408
+ arbiter.pointer_up(pointer, x, y, t)
409
+ elif phase == "cancel":
410
+ arbiter.cancel(t)
411
+ elif phase == "clear":
412
+ self._gestures.pop(tag, None)
413
+ return
414
+ self._schedule_gesture_poll()
415
+
416
+ def _arbiter_for(self, tag: int, specs: Any) -> Any:
417
+ if not isinstance(specs, list) or not specs:
418
+ self._gestures.pop(tag, None)
419
+ return None
420
+ key = json.dumps(specs, sort_keys=True)
421
+ existing = self._gestures.get(tag)
422
+ if existing is not None and existing[0] == key:
423
+ return existing[1]
424
+ from ..events import dispatch_event
425
+ from ..gestures import make_arbiter
426
+
427
+ def _emit(index: int, payload: Dict[str, Any]) -> None:
428
+ dispatch_event(tag, f"gesture:{index}", payload)
429
+
430
+ arbiter = make_arbiter([s for s in specs if isinstance(s, dict)], _emit)
431
+ self._gestures[tag] = (key, arbiter)
432
+ return arbiter
433
+
434
+ def _schedule_gesture_poll(self) -> None:
435
+ """Arm one timer for the earliest recognizer deadline (long press, multi-tap windows)."""
436
+ deadlines = [d for _, arb in self._gestures.values() if (d := arb.next_deadline()) is not None]
437
+ if not deadlines:
438
+ return
439
+ delay = max(0.0, min(deadlines) - time.monotonic())
440
+ from ..runtime import get_loop
441
+
442
+ loop = get_loop()
443
+ if self._gesture_timer is not None:
444
+ try:
445
+ self._gesture_timer.cancel()
446
+ except Exception:
447
+ pass
448
+ self._gesture_timer = loop.call_later(delay + 0.001, self._poll_gestures)
449
+
450
+ def _poll_gestures(self) -> None:
451
+ self._gesture_timer = None
452
+ now = time.monotonic()
453
+ for _, arbiter in list(self._gestures.values()):
454
+ arbiter.poll(now)
455
+ self._schedule_gesture_poll()
456
+
457
+ # -- Python module fallbacks ------------------------------------------
458
+
459
+ def _call_python_module(self, module: str, method: str, args_json: str) -> Optional[str]:
460
+ from ..native_modules.registry import NativeModuleError, PythonModule, dispatch_module_message
461
+
462
+ envelope = codec.loads(args_json) or {}
463
+ args = envelope.get("args") if isinstance(envelope, dict) else None
464
+ call_id = int(envelope.get("call_id", 0) or 0) if isinstance(envelope, dict) else 0
465
+ # One instance per module for the life of the transport, so stateful
466
+ # fallbacks (Storage, SecureStore, ...) remember between calls.
467
+ impl = self._python_modules.get(module)
468
+ if impl is None:
469
+ impl = self._python_modules[module] = PythonModule(module)
470
+ try:
471
+ fn = impl._method(method)
472
+ value = fn(**(args or {}))
473
+ except KeyError:
474
+ return codec.dumps({"ok": False, "error": f"no module named {module!r}", "code": "unknown_module"})
475
+ except NativeModuleError as exc:
476
+ return codec.dumps({"ok": False, "error": exc.message, "code": exc.code})
477
+ except Exception as exc:
478
+ return codec.dumps({"ok": False, "error": str(exc)})
479
+ if hasattr(value, "__await__"):
480
+ if call_id == 0:
481
+ return codec.dumps({"ok": False, "error": f"{module}.{method} is asynchronous; use call_async()"})
482
+ from ..runtime import run_async
483
+
484
+ async def _finish() -> None:
485
+ try:
486
+ result = await value
487
+ dispatch_module_message(
488
+ module, {"call_id": call_id, "ok": True, "value": codec.to_jsonable(result)}
489
+ )
490
+ except Exception as exc:
491
+ dispatch_module_message(module, {"call_id": call_id, "ok": False, "error": str(exc)})
492
+
493
+ run_async(_finish())
494
+ return codec.dumps({"pending": True})
495
+ try:
496
+ return codec.dumps({"ok": True, "value": codec.to_jsonable(value)})
497
+ except TypeError:
498
+ return codec.dumps({"ok": True, "value": None})
499
+
500
+
501
+ _UNBOUNDED = 1e6
502
+ """Wire sentinel for an unconstrained measure axis (matches the native runtimes)."""
503
+
504
+
505
+ def _finite(value: Any) -> float:
506
+ """JSON has no infinity: clamp unconstrained measure axes to the wire sentinel."""
507
+ try:
508
+ f = float(value)
509
+ except (TypeError, ValueError):
510
+ return _UNBOUNDED
511
+ if f != f or f > _UNBOUNDED or f == float("inf"):
512
+ return _UNBOUNDED
513
+ return max(0.0, f)
514
+
515
+
516
+ def _as_json_text(result: Any) -> Optional[str]:
517
+ """Normalize a page result to the JSON text the native protocol returns."""
518
+ if result is None:
519
+ return None
520
+ if isinstance(result, str):
521
+ return result
522
+ return codec.dumps(result)
523
+
524
+
525
+ def _payload_text(payload: Any) -> str:
526
+ if payload is None:
527
+ return ""
528
+ if isinstance(payload, str):
529
+ return payload
530
+ return codec.dumps(payload)