aspectly-bridge 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Zhan Isaakian
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: aspectly-bridge
3
+ Version: 2.1.0
4
+ Summary: Type-safe, bidirectional bridge between native Python (WebKitGTK) and JavaScript via the Aspectly protocol.
5
+ Author-email: Zhan Isaakian <jeanisahkyan@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/JeanIsahakyan/aspectly
8
+ Project-URL: Repository, https://github.com/JeanIsahakyan/aspectly
9
+ Keywords: aspectly,bridge,webkitgtk,webview,javascript,ipc
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Requires-Python: >=3.7
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: webkitgtk
17
+ Requires-Dist: PyGObject>=3.40; extra == "webkitgtk"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # aspectly-bridge (Python / WebKitGTK)
23
+
24
+ [![PyPI](https://img.shields.io/pypi/v/aspectly-bridge?style=flat-square&logo=pypi&color=3776AB)](https://pypi.org/project/aspectly-bridge/)
25
+
26
+ Type-safe, bidirectional bridge between native Python code and JavaScript
27
+ running in a **WebKitGTK** web view (Linux desktop). Speaks the
28
+ [Aspectly](https://github.com/JeanIsahakyan/aspectly) protocol, so the same
29
+ `@aspectly/core` web content runs unchanged across every host.
30
+
31
+ > WebKitGTK uses the **same** JS mechanism as WKWebView
32
+ > (`window.webkit.messageHandlers.aspectly`), so the embedded web content
33
+ > auto-detects this host through the existing `@aspectly/transports` WebKit
34
+ > transport — no Flutter/Android-style extra transport needed.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install aspectly-bridge
40
+
41
+ # For the WebKitGTK browser bridge (Linux):
42
+ # system packages: gobject-introspection, gir1.2-webkit2-4.1 (or 4.0)
43
+ pip install "aspectly-bridge[webkitgtk]"
44
+ ```
45
+
46
+ The core (`BridgeHost`) is pure Python with no dependencies. Only the
47
+ `WebKitGTKBrowserBridge` needs PyGObject + WebKit2GTK (Linux).
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from aspectly_bridge import BridgeHost
53
+ from aspectly_bridge.webkitgtk import WebKitGTKBrowserBridge
54
+
55
+ bridge = BridgeHost(WebKitGTKBrowserBridge(web_view))
56
+
57
+ # Register handlers JS can call (before initialize).
58
+ bridge.register_handler("ping", lambda params: "pong")
59
+ bridge.register_handler("add", lambda params: params["a"] + params["b"])
60
+
61
+ # Initialize (returns a Future; resolves when the handshake completes).
62
+ bridge.initialize().result()
63
+
64
+ # Call a JS method (returns a Future).
65
+ result = bridge.send("greet", {"name": "Python"}).result()
66
+ print(result["message"])
67
+ ```
68
+
69
+ `send` and `initialize` return `concurrent.futures.Future`. In a GTK app, the
70
+ result is delivered on the GLib main thread, so use `future.add_done_callback`
71
+ or `GLib.idle_add` rather than blocking `.result()` on the UI thread.
72
+
73
+ ## API
74
+
75
+ ```python
76
+ BridgeHost(browser_bridge, logger=None, timeout_ms=100000)
77
+
78
+ register_handler(method, handler) # handler(params) -> result
79
+ unregister_handler(method)
80
+ initialize(handlers=None) -> Future
81
+ send(method, params=None, timeout_ms=None) -> Future
82
+ process_message(message_json)
83
+
84
+ is_initialized # bool
85
+ supported_methods # list[str] (JS-side methods)
86
+ registered_methods # list[str] (Python-side handlers)
87
+ on_initialized # callable or None
88
+ dispose()
89
+ ```
90
+
91
+ `send` / handlers surface `BridgeException` with `error_type` one of
92
+ `BridgeErrorType.{UNSUPPORTED_METHOD, METHOD_EXECUTION_TIMEOUT, REJECTED, BRIDGE_NOT_AVAILABLE}`.
93
+
94
+ ## Testing
95
+
96
+ ```bash
97
+ cd python
98
+ python -m pytest # the pure-Python core (no GTK required)
99
+ ```
100
+
101
+ See [`examples/webkitgtk`](../examples/webkitgtk) for a runnable GTK app.
102
+
103
+ ## Other platforms
104
+
105
+ This is the Python / WebKitGTK host (PyPI `aspectly-bridge`, **2.1.0**). The same
106
+ Aspectly protocol ships for Web (`@aspectly/web`), React Native
107
+ (`@aspectly/react-native`), React Native Web/Expo
108
+ (`@aspectly/react-native-web`), .NET CefSharp/WebView2
109
+ (`Aspectly.Bridge.CefSharp` / `Aspectly.Bridge.WebView2`), iOS/macOS/visionOS
110
+ (`AspectlyBridge`), Android (`io.github.jeanisahakyan:aspectly-bridge`), and Flutter
111
+ (`aspectly_bridge`) — all at version **2.1.0**. See the
112
+ [repository README](../README.md) for the full platform matrix.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,95 @@
1
+ # aspectly-bridge (Python / WebKitGTK)
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/aspectly-bridge?style=flat-square&logo=pypi&color=3776AB)](https://pypi.org/project/aspectly-bridge/)
4
+
5
+ Type-safe, bidirectional bridge between native Python code and JavaScript
6
+ running in a **WebKitGTK** web view (Linux desktop). Speaks the
7
+ [Aspectly](https://github.com/JeanIsahakyan/aspectly) protocol, so the same
8
+ `@aspectly/core` web content runs unchanged across every host.
9
+
10
+ > WebKitGTK uses the **same** JS mechanism as WKWebView
11
+ > (`window.webkit.messageHandlers.aspectly`), so the embedded web content
12
+ > auto-detects this host through the existing `@aspectly/transports` WebKit
13
+ > transport — no Flutter/Android-style extra transport needed.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install aspectly-bridge
19
+
20
+ # For the WebKitGTK browser bridge (Linux):
21
+ # system packages: gobject-introspection, gir1.2-webkit2-4.1 (or 4.0)
22
+ pip install "aspectly-bridge[webkitgtk]"
23
+ ```
24
+
25
+ The core (`BridgeHost`) is pure Python with no dependencies. Only the
26
+ `WebKitGTKBrowserBridge` needs PyGObject + WebKit2GTK (Linux).
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from aspectly_bridge import BridgeHost
32
+ from aspectly_bridge.webkitgtk import WebKitGTKBrowserBridge
33
+
34
+ bridge = BridgeHost(WebKitGTKBrowserBridge(web_view))
35
+
36
+ # Register handlers JS can call (before initialize).
37
+ bridge.register_handler("ping", lambda params: "pong")
38
+ bridge.register_handler("add", lambda params: params["a"] + params["b"])
39
+
40
+ # Initialize (returns a Future; resolves when the handshake completes).
41
+ bridge.initialize().result()
42
+
43
+ # Call a JS method (returns a Future).
44
+ result = bridge.send("greet", {"name": "Python"}).result()
45
+ print(result["message"])
46
+ ```
47
+
48
+ `send` and `initialize` return `concurrent.futures.Future`. In a GTK app, the
49
+ result is delivered on the GLib main thread, so use `future.add_done_callback`
50
+ or `GLib.idle_add` rather than blocking `.result()` on the UI thread.
51
+
52
+ ## API
53
+
54
+ ```python
55
+ BridgeHost(browser_bridge, logger=None, timeout_ms=100000)
56
+
57
+ register_handler(method, handler) # handler(params) -> result
58
+ unregister_handler(method)
59
+ initialize(handlers=None) -> Future
60
+ send(method, params=None, timeout_ms=None) -> Future
61
+ process_message(message_json)
62
+
63
+ is_initialized # bool
64
+ supported_methods # list[str] (JS-side methods)
65
+ registered_methods # list[str] (Python-side handlers)
66
+ on_initialized # callable or None
67
+ dispose()
68
+ ```
69
+
70
+ `send` / handlers surface `BridgeException` with `error_type` one of
71
+ `BridgeErrorType.{UNSUPPORTED_METHOD, METHOD_EXECUTION_TIMEOUT, REJECTED, BRIDGE_NOT_AVAILABLE}`.
72
+
73
+ ## Testing
74
+
75
+ ```bash
76
+ cd python
77
+ python -m pytest # the pure-Python core (no GTK required)
78
+ ```
79
+
80
+ See [`examples/webkitgtk`](../examples/webkitgtk) for a runnable GTK app.
81
+
82
+ ## Other platforms
83
+
84
+ This is the Python / WebKitGTK host (PyPI `aspectly-bridge`, **2.1.0**). The same
85
+ Aspectly protocol ships for Web (`@aspectly/web`), React Native
86
+ (`@aspectly/react-native`), React Native Web/Expo
87
+ (`@aspectly/react-native-web`), .NET CefSharp/WebView2
88
+ (`Aspectly.Bridge.CefSharp` / `Aspectly.Bridge.WebView2`), iOS/macOS/visionOS
89
+ (`AspectlyBridge`), Android (`io.github.jeanisahakyan:aspectly-bridge`), and Flutter
90
+ (`aspectly_bridge`) — all at version **2.1.0**. See the
91
+ [repository README](../README.md) for the full platform matrix.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,22 @@
1
+ """Aspectly bridge for Python — type-safe, bidirectional communication between
2
+ native Python (WebKitGTK) and JavaScript via the Aspectly protocol."""
3
+
4
+ from .bridge_host import BridgeHost, DEFAULT_TIMEOUT_MS
5
+ from .browser_bridge import BrowserBridge, BridgeLogger, NullLogger, ConsoleLogger
6
+ from .errors import BridgeException
7
+ from .protocol import BridgeErrorType, BridgeEventType, BridgeResultType
8
+
9
+ __all__ = [
10
+ "BridgeHost",
11
+ "DEFAULT_TIMEOUT_MS",
12
+ "BrowserBridge",
13
+ "BridgeLogger",
14
+ "NullLogger",
15
+ "ConsoleLogger",
16
+ "BridgeException",
17
+ "BridgeErrorType",
18
+ "BridgeEventType",
19
+ "BridgeResultType",
20
+ ]
21
+
22
+ __version__ = "2.1.0"
@@ -0,0 +1,295 @@
1
+ import json
2
+ import threading
3
+ from concurrent.futures import Future
4
+
5
+ from .browser_bridge import NullLogger
6
+ from .errors import BridgeException
7
+ from .protocol import BridgeErrorType, BridgeEventType, BridgeResultType
8
+
9
+ _BRIDGE_EVENT_TYPE = "BridgeEvent"
10
+ DEFAULT_TIMEOUT_MS = 100000
11
+
12
+
13
+ class BridgeHost(object):
14
+ """Manages bidirectional communication between native Python code and
15
+ JavaScript via the Aspectly protocol. The Python equivalent of the .NET
16
+ ``BridgeHost``.
17
+
18
+ The host is loop-agnostic: ``send`` and ``initialize`` return
19
+ ``concurrent.futures.Future`` objects, and handlers are plain callables
20
+ ``handler(params) -> result`` (returning a JSON-encodable value or raising).
21
+ """
22
+
23
+ def __init__(self, browser_bridge, logger=None, timeout_ms=DEFAULT_TIMEOUT_MS):
24
+ self._browser = browser_bridge
25
+ self._logger = logger if logger is not None else NullLogger()
26
+ self._timeout_ms = timeout_ms
27
+ self._lock = threading.RLock()
28
+
29
+ self._initialized = False
30
+ self._remote_init_received = False
31
+ self._init_result_received = False
32
+ self._disposed = False
33
+ self._request_counter = 0
34
+ self._init_future = None
35
+
36
+ self._supported_methods = []
37
+ self._handlers = {}
38
+ self._pending = {}
39
+
40
+ self.on_initialized = None
41
+
42
+ self._browser.on_message = self.process_message
43
+ self._logger.info("[BridgeHost] Created and subscribed to messages")
44
+
45
+ # region properties
46
+
47
+ @property
48
+ def is_initialized(self):
49
+ with self._lock:
50
+ return self._initialized
51
+
52
+ @property
53
+ def supported_methods(self):
54
+ with self._lock:
55
+ return list(self._supported_methods)
56
+
57
+ @property
58
+ def registered_methods(self):
59
+ with self._lock:
60
+ return list(self._handlers.keys())
61
+
62
+ # region handler registration
63
+
64
+ def register_handler(self, method, handler):
65
+ with self._lock:
66
+ self._handlers[method] = handler
67
+ self._logger.info("[BridgeHost] Registered handler: %s" % method)
68
+
69
+ def unregister_handler(self, method):
70
+ with self._lock:
71
+ self._handlers.pop(method, None)
72
+ self._logger.info("[BridgeHost] Unregistered handler: %s" % method)
73
+
74
+ # region incoming
75
+
76
+ def process_message(self, message_json):
77
+ if not message_json:
78
+ return
79
+ self._logger.debug("[BridgeHost] Received: %s" % message_json)
80
+
81
+ try:
82
+ wrapper = json.loads(message_json)
83
+ except (ValueError, TypeError):
84
+ self._logger.debug("[BridgeHost] JSON parse error")
85
+ return
86
+
87
+ if not isinstance(wrapper, dict) or wrapper.get("type") != _BRIDGE_EVENT_TYPE:
88
+ return
89
+ event = wrapper.get("event")
90
+ if not isinstance(event, dict):
91
+ return
92
+ event_type = event.get("type")
93
+ data = event.get("data")
94
+
95
+ if event_type == BridgeEventType.INIT:
96
+ self._handle_init(data)
97
+ elif event_type == BridgeEventType.REQUEST:
98
+ self._handle_request(data)
99
+ elif event_type == BridgeEventType.RESULT:
100
+ self._handle_result(data)
101
+ elif event_type == BridgeEventType.INIT_RESULT:
102
+ with self._lock:
103
+ self._init_result_received = True
104
+ self._logger.info("[BridgeHost] InitResult received from JS")
105
+ self._try_resolve_init()
106
+
107
+ def _handle_init(self, data):
108
+ if isinstance(data, dict) and isinstance(data.get("methods"), list):
109
+ with self._lock:
110
+ self._supported_methods = [str(m) for m in data["methods"]]
111
+ self._logger.info(
112
+ "[BridgeHost] JS supports methods: %s" % ", ".join(self._supported_methods)
113
+ )
114
+ with self._lock:
115
+ self._remote_init_received = True
116
+ # Match the JS protocol: only send InitResult, not our Init.
117
+ self._send_event(BridgeEventType.INIT_RESULT, True)
118
+ self._logger.info("[BridgeHost] Sent InitResult")
119
+ self._try_resolve_init()
120
+
121
+ def _try_resolve_init(self):
122
+ with self._lock:
123
+ should = (
124
+ self._init_result_received
125
+ and self._remote_init_received
126
+ and not self._initialized
127
+ )
128
+ if should:
129
+ self._initialized = True
130
+ future = self._init_future
131
+ self._init_future = None
132
+ else:
133
+ future = None
134
+ if should:
135
+ if future is not None and not future.done():
136
+ future.set_result(None)
137
+ self._logger.info("[BridgeHost] Bridge fully initialized")
138
+ callback = self.on_initialized
139
+ if callback is not None:
140
+ callback()
141
+
142
+ def _handle_request(self, data):
143
+ if not isinstance(data, dict):
144
+ return
145
+ method = data.get("method")
146
+ request_id = data.get("request_id")
147
+ if method is None or request_id is None:
148
+ return
149
+ params = data.get("params", {})
150
+
151
+ with self._lock:
152
+ handler = self._handlers.get(method)
153
+
154
+ if handler is None:
155
+ self._logger.warn("[BridgeHost] Unknown method: %s" % method)
156
+ result = self._error_result(
157
+ method, request_id, BridgeErrorType.UNSUPPORTED_METHOD,
158
+ "Method '%s' is not registered" % method,
159
+ )
160
+ else:
161
+ try:
162
+ value = handler(params)
163
+ result = {
164
+ "type": BridgeResultType.SUCCESS,
165
+ "method": method,
166
+ "request_id": request_id,
167
+ "data": value,
168
+ }
169
+ self._logger.debug("[BridgeHost] Handler '%s' completed successfully" % method)
170
+ except BridgeException as e:
171
+ self._logger.error("[BridgeHost] Handler '%s' failed" % method, e)
172
+ result = self._error_result(method, request_id, BridgeErrorType.REJECTED, e.message)
173
+ except Exception as e: # noqa: BLE001
174
+ self._logger.error("[BridgeHost] Handler '%s' failed" % method, e)
175
+ result = self._error_result(method, request_id, BridgeErrorType.REJECTED, str(e))
176
+
177
+ self._send_event(BridgeEventType.RESULT, result)
178
+
179
+ def _handle_result(self, data):
180
+ if not isinstance(data, dict):
181
+ return
182
+ request_id = data.get("request_id")
183
+ if request_id is None:
184
+ return
185
+ with self._lock:
186
+ future = self._pending.pop(request_id, None)
187
+ if future is None or future.done():
188
+ return
189
+
190
+ if data.get("type") == BridgeResultType.SUCCESS:
191
+ future.set_result(data.get("data"))
192
+ else:
193
+ error = data.get("error") or {}
194
+ error_type = error.get("error_type") or BridgeErrorType.REJECTED
195
+ message = error.get("error_message")
196
+ future.set_exception(BridgeException(error_type, message))
197
+
198
+ # region outgoing
199
+
200
+ def _send_event(self, event_type, data):
201
+ wrapper = {"type": _BRIDGE_EVENT_TYPE, "event": {"type": event_type, "data": data}}
202
+ json_text = json.dumps(wrapper)
203
+ # Double-encode to produce a safe JS string literal (matches .NET / Swift).
204
+ js_literal = json.dumps(json_text)
205
+ script = "(function(){window.postMessage(%s, '*');return true;})();" % js_literal
206
+ try:
207
+ self._browser.execute_script(script)
208
+ self._logger.debug("[BridgeHost] Sent: %s" % json_text)
209
+ except Exception as e: # noqa: BLE001
210
+ self._logger.error("[BridgeHost] Failed to send event", e)
211
+
212
+ def send(self, method, params=None, timeout_ms=None):
213
+ """Send a request to the JavaScript side. Returns a ``Future`` that
214
+ resolves with the result or raises ``BridgeException``."""
215
+ if not self.is_initialized:
216
+ raise BridgeException(BridgeErrorType.BRIDGE_NOT_AVAILABLE, "Bridge not initialized")
217
+ with self._lock:
218
+ supported = method in self._supported_methods
219
+ if not supported:
220
+ raise BridgeException(
221
+ BridgeErrorType.UNSUPPORTED_METHOD,
222
+ "Method '%s' not supported by JS side" % method,
223
+ )
224
+
225
+ timeout = timeout_ms if timeout_ms is not None else self._timeout_ms
226
+ with self._lock:
227
+ self._request_counter += 1
228
+ request_id = str(self._request_counter)
229
+ future = Future()
230
+ self._pending[request_id] = future
231
+
232
+ self._send_event(BridgeEventType.REQUEST, {
233
+ "method": method,
234
+ "request_id": request_id,
235
+ "params": params if params is not None else {},
236
+ })
237
+ self._logger.debug("[BridgeHost] Sent request: %s (id=%s)" % (method, request_id))
238
+
239
+ def on_timeout():
240
+ with self._lock:
241
+ pending = self._pending.pop(request_id, None)
242
+ if pending is not None and not pending.done():
243
+ pending.set_exception(BridgeException(
244
+ BridgeErrorType.METHOD_EXECUTION_TIMEOUT,
245
+ "Request '%s' timed out after %dms" % (method, timeout),
246
+ ))
247
+
248
+ timer = threading.Timer(timeout / 1000.0, on_timeout)
249
+ timer.daemon = True
250
+ timer.start()
251
+ future.add_done_callback(lambda _f: timer.cancel())
252
+ return future
253
+
254
+ def initialize(self, handlers=None):
255
+ """Register handlers and send ``Init``; returns a ``Future`` that
256
+ resolves when the handshake completes."""
257
+ if handlers:
258
+ for method, handler in handlers.items():
259
+ self.register_handler(method, handler)
260
+ with self._lock:
261
+ future = Future()
262
+ self._init_future = future
263
+ methods = list(self._handlers.keys())
264
+ self._send_event(BridgeEventType.INIT, {"methods": methods})
265
+ self._logger.info("[BridgeHost] Sent Init with methods: %s" % ", ".join(methods))
266
+ return future
267
+
268
+ def dispose(self):
269
+ with self._lock:
270
+ if self._disposed:
271
+ return
272
+ self._disposed = True
273
+ pending = list(self._pending.values())
274
+ self._pending.clear()
275
+ init_future = self._init_future
276
+ self._init_future = None
277
+
278
+ self._browser.on_message = None
279
+ self._browser.dispose()
280
+
281
+ cancellation = BridgeException(BridgeErrorType.BRIDGE_NOT_AVAILABLE, "Bridge disposed")
282
+ if init_future is not None and not init_future.done():
283
+ init_future.set_exception(cancellation)
284
+ for future in pending:
285
+ if not future.done():
286
+ future.set_exception(cancellation)
287
+ self._logger.info("[BridgeHost] Disposed")
288
+
289
+ def _error_result(self, method, request_id, error_type, message):
290
+ return {
291
+ "type": BridgeResultType.ERROR,
292
+ "method": method,
293
+ "request_id": request_id,
294
+ "error": {"error_type": error_type, "error_message": message},
295
+ }
@@ -0,0 +1,81 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BrowserBridge(ABC):
5
+ """Abstraction for browser message passing.
6
+
7
+ Implement this for any web view (WebKitGTK, a mock for testing, etc.).
8
+ The Python equivalent of .NET's ``IBrowserBridge``.
9
+
10
+ ``BridgeHost`` assigns ``on_message`` (a callable taking the message string)
11
+ when it is constructed.
12
+ """
13
+
14
+ on_message = None
15
+
16
+ @property
17
+ @abstractmethod
18
+ def is_ready(self):
19
+ """Whether the browser is ready for communication."""
20
+
21
+ @abstractmethod
22
+ def execute_script(self, script):
23
+ """Send a message to JavaScript by executing ``script``."""
24
+
25
+ @abstractmethod
26
+ def dispose(self):
27
+ """Release resources and detach from the underlying web view."""
28
+
29
+
30
+ class BridgeLogger(ABC):
31
+ """Simple logging interface for ``BridgeHost``."""
32
+
33
+ @abstractmethod
34
+ def debug(self, message):
35
+ ...
36
+
37
+ @abstractmethod
38
+ def info(self, message):
39
+ ...
40
+
41
+ @abstractmethod
42
+ def warn(self, message):
43
+ ...
44
+
45
+ @abstractmethod
46
+ def error(self, message, error=None):
47
+ ...
48
+
49
+
50
+ class NullLogger(BridgeLogger):
51
+ """Discards all log messages."""
52
+
53
+ def debug(self, message):
54
+ pass
55
+
56
+ def info(self, message):
57
+ pass
58
+
59
+ def warn(self, message):
60
+ pass
61
+
62
+ def error(self, message, error=None):
63
+ pass
64
+
65
+
66
+ class ConsoleLogger(BridgeLogger):
67
+ """Prints log messages to stdout."""
68
+
69
+ def debug(self, message):
70
+ print("[DEBUG] %s" % message)
71
+
72
+ def info(self, message):
73
+ print("[INFO] %s" % message)
74
+
75
+ def warn(self, message):
76
+ print("[WARN] %s" % message)
77
+
78
+ def error(self, message, error=None):
79
+ print("[ERROR] %s" % message)
80
+ if error is not None:
81
+ print(" Error: %s" % error)
@@ -0,0 +1,16 @@
1
+ from .protocol import BridgeErrorType
2
+
3
+
4
+ class BridgeException(Exception):
5
+ """Exception type for bridge protocol errors.
6
+
7
+ The Python equivalent of .NET's ``BridgeException`` / Swift's ``BridgeError``.
8
+ """
9
+
10
+ def __init__(self, error_type, message=None):
11
+ self.error_type = error_type
12
+ self.message = message if message is not None else error_type
13
+ super().__init__(self.message)
14
+
15
+ def __repr__(self):
16
+ return "BridgeException(%s): %s" % (self.error_type, self.message)
@@ -0,0 +1,20 @@
1
+ """Protocol constants for the Aspectly bridge. Values match @aspectly/core."""
2
+
3
+
4
+ class BridgeEventType:
5
+ INIT = "Init"
6
+ INIT_RESULT = "InitResult"
7
+ REQUEST = "Request"
8
+ RESULT = "Result"
9
+
10
+
11
+ class BridgeResultType:
12
+ SUCCESS = "Success"
13
+ ERROR = "Error"
14
+
15
+
16
+ class BridgeErrorType:
17
+ METHOD_EXECUTION_TIMEOUT = "METHOD_EXECUTION_TIMEOUT"
18
+ UNSUPPORTED_METHOD = "UNSUPPORTED_METHOD"
19
+ REJECTED = "REJECTED"
20
+ BRIDGE_NOT_AVAILABLE = "BRIDGE_NOT_AVAILABLE"
@@ -0,0 +1,76 @@
1
+ """WebKitGTK browser bridge (Linux). Requires PyGObject + WebKit2GTK.
2
+
3
+ JS <-> native uses the same mechanism as WKWebView, so the embedded web content
4
+ auto-detects this host via the ``@aspectly/transports`` WebKit transport
5
+ (``window.webkit.messageHandlers.aspectly``) — no extra JS is required.
6
+ """
7
+
8
+ from .browser_bridge import BrowserBridge
9
+
10
+ _GTK_IMPORT_ERROR = None
11
+ try: # pragma: no cover - exercised only on Linux with GTK installed
12
+ import gi
13
+
14
+ gi.require_version("WebKit2", "4.1")
15
+ from gi.repository import WebKit2 # noqa: F401
16
+
17
+ _HAS_GTK = True
18
+ except Exception as exc: # noqa: BLE001
19
+ _HAS_GTK = False
20
+ _GTK_IMPORT_ERROR = exc
21
+
22
+
23
+ class WebKitGTKBrowserBridge(BrowserBridge):
24
+ """``BrowserBridge`` implementation for a WebKitGTK ``WebKit2.WebView``.
25
+
26
+ - JS -> native: ``window.webkit.messageHandlers.<name>.postMessage(message)``
27
+ delivered via a registered script message handler.
28
+ - native -> JS: ``web_view.run_javascript("window.postMessage(...)")``.
29
+
30
+ All WebView access must happen on the GTK main thread.
31
+ """
32
+
33
+ DEFAULT_HANDLER_NAME = "aspectly"
34
+
35
+ def __init__(self, web_view, handler_name=DEFAULT_HANDLER_NAME):
36
+ if not _HAS_GTK:
37
+ raise RuntimeError(
38
+ "WebKitGTKBrowserBridge requires PyGObject + WebKit2GTK (Linux). "
39
+ "Import failed: %s" % _GTK_IMPORT_ERROR
40
+ )
41
+ self._web_view = web_view
42
+ self._handler_name = handler_name
43
+ self._disposed = False
44
+ self.on_message = None
45
+
46
+ ucm = web_view.get_user_content_manager()
47
+ ucm.register_script_message_handler(handler_name)
48
+ ucm.connect("script-message-received::" + handler_name, self._on_script_message)
49
+
50
+ @property
51
+ def is_ready(self):
52
+ return not self._disposed
53
+
54
+ def _on_script_message(self, user_content_manager, js_message):
55
+ try:
56
+ value = js_message.get_js_value().to_string()
57
+ except Exception: # noqa: BLE001
58
+ value = None
59
+ if value is not None and self.on_message is not None:
60
+ self.on_message(value)
61
+
62
+ def execute_script(self, script):
63
+ # run_javascript must be called on the GTK main thread.
64
+ self._web_view.run_javascript(script, None, None, None)
65
+
66
+ def dispose(self):
67
+ if self._disposed:
68
+ return
69
+ self._disposed = True
70
+ self.on_message = None
71
+ try:
72
+ self._web_view.get_user_content_manager().unregister_script_message_handler(
73
+ self._handler_name
74
+ )
75
+ except Exception: # noqa: BLE001
76
+ pass
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: aspectly-bridge
3
+ Version: 2.1.0
4
+ Summary: Type-safe, bidirectional bridge between native Python (WebKitGTK) and JavaScript via the Aspectly protocol.
5
+ Author-email: Zhan Isaakian <jeanisahkyan@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/JeanIsahakyan/aspectly
8
+ Project-URL: Repository, https://github.com/JeanIsahakyan/aspectly
9
+ Keywords: aspectly,bridge,webkitgtk,webview,javascript,ipc
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Requires-Python: >=3.7
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: webkitgtk
17
+ Requires-Dist: PyGObject>=3.40; extra == "webkitgtk"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # aspectly-bridge (Python / WebKitGTK)
23
+
24
+ [![PyPI](https://img.shields.io/pypi/v/aspectly-bridge?style=flat-square&logo=pypi&color=3776AB)](https://pypi.org/project/aspectly-bridge/)
25
+
26
+ Type-safe, bidirectional bridge between native Python code and JavaScript
27
+ running in a **WebKitGTK** web view (Linux desktop). Speaks the
28
+ [Aspectly](https://github.com/JeanIsahakyan/aspectly) protocol, so the same
29
+ `@aspectly/core` web content runs unchanged across every host.
30
+
31
+ > WebKitGTK uses the **same** JS mechanism as WKWebView
32
+ > (`window.webkit.messageHandlers.aspectly`), so the embedded web content
33
+ > auto-detects this host through the existing `@aspectly/transports` WebKit
34
+ > transport — no Flutter/Android-style extra transport needed.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install aspectly-bridge
40
+
41
+ # For the WebKitGTK browser bridge (Linux):
42
+ # system packages: gobject-introspection, gir1.2-webkit2-4.1 (or 4.0)
43
+ pip install "aspectly-bridge[webkitgtk]"
44
+ ```
45
+
46
+ The core (`BridgeHost`) is pure Python with no dependencies. Only the
47
+ `WebKitGTKBrowserBridge` needs PyGObject + WebKit2GTK (Linux).
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from aspectly_bridge import BridgeHost
53
+ from aspectly_bridge.webkitgtk import WebKitGTKBrowserBridge
54
+
55
+ bridge = BridgeHost(WebKitGTKBrowserBridge(web_view))
56
+
57
+ # Register handlers JS can call (before initialize).
58
+ bridge.register_handler("ping", lambda params: "pong")
59
+ bridge.register_handler("add", lambda params: params["a"] + params["b"])
60
+
61
+ # Initialize (returns a Future; resolves when the handshake completes).
62
+ bridge.initialize().result()
63
+
64
+ # Call a JS method (returns a Future).
65
+ result = bridge.send("greet", {"name": "Python"}).result()
66
+ print(result["message"])
67
+ ```
68
+
69
+ `send` and `initialize` return `concurrent.futures.Future`. In a GTK app, the
70
+ result is delivered on the GLib main thread, so use `future.add_done_callback`
71
+ or `GLib.idle_add` rather than blocking `.result()` on the UI thread.
72
+
73
+ ## API
74
+
75
+ ```python
76
+ BridgeHost(browser_bridge, logger=None, timeout_ms=100000)
77
+
78
+ register_handler(method, handler) # handler(params) -> result
79
+ unregister_handler(method)
80
+ initialize(handlers=None) -> Future
81
+ send(method, params=None, timeout_ms=None) -> Future
82
+ process_message(message_json)
83
+
84
+ is_initialized # bool
85
+ supported_methods # list[str] (JS-side methods)
86
+ registered_methods # list[str] (Python-side handlers)
87
+ on_initialized # callable or None
88
+ dispose()
89
+ ```
90
+
91
+ `send` / handlers surface `BridgeException` with `error_type` one of
92
+ `BridgeErrorType.{UNSUPPORTED_METHOD, METHOD_EXECUTION_TIMEOUT, REJECTED, BRIDGE_NOT_AVAILABLE}`.
93
+
94
+ ## Testing
95
+
96
+ ```bash
97
+ cd python
98
+ python -m pytest # the pure-Python core (no GTK required)
99
+ ```
100
+
101
+ See [`examples/webkitgtk`](../examples/webkitgtk) for a runnable GTK app.
102
+
103
+ ## Other platforms
104
+
105
+ This is the Python / WebKitGTK host (PyPI `aspectly-bridge`, **2.1.0**). The same
106
+ Aspectly protocol ships for Web (`@aspectly/web`), React Native
107
+ (`@aspectly/react-native`), React Native Web/Expo
108
+ (`@aspectly/react-native-web`), .NET CefSharp/WebView2
109
+ (`Aspectly.Bridge.CefSharp` / `Aspectly.Bridge.WebView2`), iOS/macOS/visionOS
110
+ (`AspectlyBridge`), Android (`io.github.jeanisahakyan:aspectly-bridge`), and Flutter
111
+ (`aspectly_bridge`) — all at version **2.1.0**. See the
112
+ [repository README](../README.md) for the full platform matrix.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ aspectly_bridge/__init__.py
5
+ aspectly_bridge/bridge_host.py
6
+ aspectly_bridge/browser_bridge.py
7
+ aspectly_bridge/errors.py
8
+ aspectly_bridge/protocol.py
9
+ aspectly_bridge/webkitgtk.py
10
+ aspectly_bridge.egg-info/PKG-INFO
11
+ aspectly_bridge.egg-info/SOURCES.txt
12
+ aspectly_bridge.egg-info/dependency_links.txt
13
+ aspectly_bridge.egg-info/requires.txt
14
+ aspectly_bridge.egg-info/top_level.txt
15
+ tests/test_bridge_host.py
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
4
+
5
+ [webkitgtk]
6
+ PyGObject>=3.40
@@ -0,0 +1 @@
1
+ aspectly_bridge
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aspectly-bridge"
7
+ version = "2.1.0"
8
+ description = "Type-safe, bidirectional bridge between native Python (WebKitGTK) and JavaScript via the Aspectly protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.7"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Zhan Isaakian", email = "jeanisahkyan@gmail.com" }]
13
+ keywords = ["aspectly", "bridge", "webkitgtk", "webview", "javascript", "ipc"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: POSIX :: Linux",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/JeanIsahakyan/aspectly"
23
+ Repository = "https://github.com/JeanIsahakyan/aspectly"
24
+
25
+ [project.optional-dependencies]
26
+ # Linux only — requires system GObject-Introspection + WebKit2GTK libraries.
27
+ webkitgtk = ["PyGObject>=3.40"]
28
+ dev = ["pytest>=7.0"]
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["aspectly_bridge*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,238 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from aspectly_bridge import (
6
+ BridgeErrorType,
7
+ BridgeException,
8
+ BridgeHost,
9
+ BrowserBridge,
10
+ )
11
+
12
+
13
+ class MockBrowserBridge(BrowserBridge):
14
+ def __init__(self):
15
+ self.on_message = None
16
+ self.sent = []
17
+ self.disposed = False
18
+
19
+ @property
20
+ def is_ready(self):
21
+ return True
22
+
23
+ def execute_script(self, script):
24
+ self.sent.append(script)
25
+
26
+ def dispose(self):
27
+ self.disposed = True
28
+
29
+
30
+ def msg(event_type, data):
31
+ return json.dumps({"type": "BridgeEvent", "event": {"type": event_type, "data": data}})
32
+
33
+
34
+ def handshake(bridge, js_methods):
35
+ fut = bridge.initialize()
36
+ bridge.process_message(msg("Init", {"methods": js_methods}))
37
+ bridge.process_message(msg("InitResult", True))
38
+ fut.result(timeout=1)
39
+
40
+
41
+ @pytest.fixture
42
+ def setup():
43
+ mock = MockBrowserBridge()
44
+ bridge = BridgeHost(mock)
45
+ yield bridge, mock
46
+ bridge.dispose()
47
+
48
+
49
+ # --- handler registration ---
50
+
51
+ def test_register_handler_adds(setup):
52
+ bridge, _ = setup
53
+ bridge.register_handler("test", lambda p: "result")
54
+ assert "test" in bridge.registered_methods
55
+
56
+
57
+ def test_unregister_handler_removes(setup):
58
+ bridge, _ = setup
59
+ bridge.register_handler("test", lambda p: "result")
60
+ bridge.unregister_handler("test")
61
+ assert "test" not in bridge.registered_methods
62
+
63
+
64
+ def test_same_name_overwrites(setup):
65
+ bridge, mock = setup
66
+ bridge.register_handler("test", lambda p: "first")
67
+ bridge.register_handler("test", lambda p: "second")
68
+ bridge.process_message(msg("Request", {"method": "test", "request_id": "1", "params": {}}))
69
+ assert "second" in mock.sent[-1]
70
+
71
+
72
+ # --- init handshake ---
73
+
74
+ def test_init_updates_supported(setup):
75
+ bridge, _ = setup
76
+ bridge.process_message(msg("Init", {"methods": ["a", "b"]}))
77
+ assert "a" in bridge.supported_methods and "b" in bridge.supported_methods
78
+
79
+
80
+ def test_handle_init_only_sends_init_result(setup):
81
+ bridge, mock = setup
82
+ bridge.register_handler("myMethod", lambda p: "r")
83
+ bridge.process_message(msg("Init", {"methods": ["jsMethod"]}))
84
+ assert len(mock.sent) == 1
85
+ assert "InitResult" in mock.sent[0]
86
+
87
+
88
+ def test_initializes_only_when_both(setup):
89
+ bridge, _ = setup
90
+ fired = []
91
+ bridge.on_initialized = lambda: fired.append(True)
92
+ bridge.process_message(msg("InitResult", True))
93
+ assert not bridge.is_initialized
94
+ assert not fired
95
+ bridge.process_message(msg("Init", {"methods": ["jsMethod"]}))
96
+ assert bridge.is_initialized
97
+ assert fired
98
+
99
+
100
+ def test_initialize_waits_for_both(setup):
101
+ bridge, _ = setup
102
+ handshake(bridge, ["jsMethod"])
103
+ assert bridge.is_initialized
104
+ assert "jsMethod" in bridge.supported_methods
105
+
106
+
107
+ def test_initialize_with_handlers(setup):
108
+ bridge, _ = setup
109
+ fut = bridge.initialize({"m1": lambda p: "r1", "m2": lambda p: "r2"})
110
+ assert "m1" in bridge.registered_methods and "m2" in bridge.registered_methods
111
+ bridge.process_message(msg("Init", {"methods": ["jsMethod"]}))
112
+ bridge.process_message(msg("InitResult", True))
113
+ fut.result(timeout=1)
114
+ assert bridge.is_initialized
115
+
116
+
117
+ # --- incoming requests ---
118
+
119
+ def test_request_calls_handler(setup):
120
+ bridge, _ = setup
121
+ called = []
122
+ bridge.register_handler("m", lambda p: called.append(True) or {"ok": True})
123
+ bridge.process_message(msg("Request", {"method": "m", "request_id": "1", "params": {}}))
124
+ assert called
125
+
126
+
127
+ def test_unknown_method_error(setup):
128
+ bridge, mock = setup
129
+ bridge.process_message(msg("Request", {"method": "nope", "request_id": "1", "params": {}}))
130
+ assert "UNSUPPORTED_METHOD" in mock.sent[-1]
131
+
132
+
133
+ def test_handler_returns_data(setup):
134
+ bridge, mock = setup
135
+ bridge.register_handler("m", lambda p: {"value": 42})
136
+ bridge.process_message(msg("Request", {"method": "m", "request_id": "1", "params": {}}))
137
+ assert "Success" in mock.sent[-1] and "42" in mock.sent[-1]
138
+
139
+
140
+ def test_handler_throws_rejected(setup):
141
+ bridge, mock = setup
142
+
143
+ def boom(p):
144
+ raise RuntimeError("boom")
145
+
146
+ bridge.register_handler("m", boom)
147
+ bridge.process_message(msg("Request", {"method": "m", "request_id": "1", "params": {}}))
148
+ assert "REJECTED" in mock.sent[-1] and "boom" in mock.sent[-1]
149
+
150
+
151
+ def test_typed_params(setup):
152
+ bridge, mock = setup
153
+ bridge.register_handler("add", lambda p: p["a"] + p["b"])
154
+ bridge.process_message(msg("Request", {"method": "add", "request_id": "1", "params": {"a": 10, "b": 20}}))
155
+ assert "30" in mock.sent[-1]
156
+
157
+
158
+ # --- sending requests ---
159
+
160
+ def test_send_success(setup):
161
+ bridge, _ = setup
162
+ handshake(bridge, ["jsMethod"])
163
+ fut = bridge.send("jsMethod")
164
+ bridge.process_message(msg("Result", {"type": "Success", "data": "test result", "request_id": "1"}))
165
+ assert fut.result(timeout=1) == "test result"
166
+
167
+
168
+ def test_send_error(setup):
169
+ bridge, _ = setup
170
+ handshake(bridge, ["jsMethod"])
171
+ fut = bridge.send("jsMethod")
172
+ bridge.process_message(msg("Result", {
173
+ "type": "Error",
174
+ "error": {"error_type": "REJECTED", "error_message": "failed"},
175
+ "request_id": "1",
176
+ }))
177
+ with pytest.raises(BridgeException) as exc:
178
+ fut.result(timeout=1)
179
+ assert exc.value.error_type == BridgeErrorType.REJECTED
180
+ assert exc.value.message == "failed"
181
+
182
+
183
+ def test_send_timeout(setup):
184
+ bridge, _ = setup
185
+ handshake(bridge, ["jsMethod"])
186
+ fut = bridge.send("jsMethod", None, 50)
187
+ with pytest.raises(BridgeException) as exc:
188
+ fut.result(timeout=1)
189
+ assert exc.value.error_type == BridgeErrorType.METHOD_EXECUTION_TIMEOUT
190
+
191
+
192
+ def test_send_before_init(setup):
193
+ bridge, _ = setup
194
+ with pytest.raises(BridgeException) as exc:
195
+ bridge.send("m")
196
+ assert exc.value.error_type == BridgeErrorType.BRIDGE_NOT_AVAILABLE
197
+
198
+
199
+ def test_send_unsupported(setup):
200
+ bridge, _ = setup
201
+ handshake(bridge, ["other"])
202
+ with pytest.raises(BridgeException) as exc:
203
+ bridge.send("m")
204
+ assert exc.value.error_type == BridgeErrorType.UNSUPPORTED_METHOD
205
+
206
+
207
+ # --- ignored messages ---
208
+
209
+ def test_empty(setup):
210
+ bridge, mock = setup
211
+ bridge.process_message("")
212
+ assert mock.sent == []
213
+
214
+
215
+ def test_invalid_json(setup):
216
+ bridge, mock = setup
217
+ bridge.process_message("not json {")
218
+ assert mock.sent == []
219
+
220
+
221
+ def test_non_bridge_event(setup):
222
+ bridge, mock = setup
223
+ bridge.process_message('{"type":"Other","data":{}}')
224
+ assert mock.sent == []
225
+
226
+
227
+ # --- dispose ---
228
+
229
+ def test_dispose_disposes_browser(setup):
230
+ bridge, mock = setup
231
+ bridge.dispose()
232
+ assert mock.disposed
233
+
234
+
235
+ def test_dispose_twice(setup):
236
+ bridge, _ = setup
237
+ bridge.dispose()
238
+ bridge.dispose()