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.
- aspectly_bridge-2.1.0/LICENSE +20 -0
- aspectly_bridge-2.1.0/PKG-INFO +116 -0
- aspectly_bridge-2.1.0/README.md +95 -0
- aspectly_bridge-2.1.0/aspectly_bridge/__init__.py +22 -0
- aspectly_bridge-2.1.0/aspectly_bridge/bridge_host.py +295 -0
- aspectly_bridge-2.1.0/aspectly_bridge/browser_bridge.py +81 -0
- aspectly_bridge-2.1.0/aspectly_bridge/errors.py +16 -0
- aspectly_bridge-2.1.0/aspectly_bridge/protocol.py +20 -0
- aspectly_bridge-2.1.0/aspectly_bridge/webkitgtk.py +76 -0
- aspectly_bridge-2.1.0/aspectly_bridge.egg-info/PKG-INFO +116 -0
- aspectly_bridge-2.1.0/aspectly_bridge.egg-info/SOURCES.txt +15 -0
- aspectly_bridge-2.1.0/aspectly_bridge.egg-info/dependency_links.txt +1 -0
- aspectly_bridge-2.1.0/aspectly_bridge.egg-info/requires.txt +6 -0
- aspectly_bridge-2.1.0/aspectly_bridge.egg-info/top_level.txt +1 -0
- aspectly_bridge-2.1.0/pyproject.toml +31 -0
- aspectly_bridge-2.1.0/setup.cfg +4 -0
- aspectly_bridge-2.1.0/tests/test_bridge_host.py +238 -0
|
@@ -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
|
+
[](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
|
+
[](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
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|