glue-ui 0.6.6__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.
- glue/__init__.py +1048 -0
- glue/__main__.py +54 -0
- glue/browsers.py +156 -0
- glue/browsers_launcher.py +82 -0
- glue/chrome.py +37 -0
- glue/edge.py +31 -0
- glue/glue.js +520 -0
- glue/py.typed +0 -0
- glue/settings.py +134 -0
- glue/types.py +51 -0
- glue/webview.py +451 -0
- glue_ui-0.6.6.dist-info/METADATA +450 -0
- glue_ui-0.6.6.dist-info/RECORD +16 -0
- glue_ui-0.6.6.dist-info/WHEEL +5 -0
- glue_ui-0.6.6.dist-info/licenses/LICENSE +22 -0
- glue_ui-0.6.6.dist-info/top_level.txt +1 -0
glue/__init__.py
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as jsn
|
|
4
|
+
import traceback
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import bottle as btl
|
|
10
|
+
import gevent as gvt
|
|
11
|
+
|
|
12
|
+
from glue.types import OptionsDictT, WebSocketT, WindowGeometryT
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import bottle_websocket as wbs
|
|
16
|
+
except ImportError:
|
|
17
|
+
import bottle.ext.websocket as wbs
|
|
18
|
+
import mimetypes
|
|
19
|
+
import os
|
|
20
|
+
import random as rnd
|
|
21
|
+
import re as rgx
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from importlib.resources import as_file, files
|
|
27
|
+
|
|
28
|
+
import pyparsing as pp
|
|
29
|
+
|
|
30
|
+
import glue.browsers as brw
|
|
31
|
+
import glue.settings as _settings
|
|
32
|
+
|
|
33
|
+
__version__ = '0.6.6'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
mimetypes.add_type('application/javascript', '.js')
|
|
37
|
+
|
|
38
|
+
_glue_js_reference = files('glue') / 'glue.js'
|
|
39
|
+
with as_file(_glue_js_reference) as _glue_js_path:
|
|
40
|
+
_glue_js: str = _glue_js_path.read_text(encoding='utf-8')
|
|
41
|
+
|
|
42
|
+
_websockets: list[tuple[Any, WebSocketT]] = []
|
|
43
|
+
_call_return_values: dict[Any, Any] = {}
|
|
44
|
+
_call_return_callbacks: dict[float, tuple[Callable[..., Any], Callable[..., Any] | None]] = {}
|
|
45
|
+
_call_number: int = 0
|
|
46
|
+
_exposed_functions: dict[Any, Any] = {}
|
|
47
|
+
_js_functions: list[Any] = []
|
|
48
|
+
_mock_queue: list[Any] = []
|
|
49
|
+
_mock_queue_done: set[Any] = set()
|
|
50
|
+
_shutdown: gvt.Greenlet | None = None # Later assigned as global by _websocket_close()
|
|
51
|
+
root_path: str # Later assigned as global by init()
|
|
52
|
+
|
|
53
|
+
# The maximum time (in milliseconds) that Python will try to retrieve a return value for functions executing in JS
|
|
54
|
+
# Can be overridden through `glue.init` with the kwarg `js_result_timeout` (default: 10000)
|
|
55
|
+
_js_result_timeout: int = 10000
|
|
56
|
+
|
|
57
|
+
# Attribute holding the start args from calls to glue.start()
|
|
58
|
+
_start_args: OptionsDictT = {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _merge_webview_options(base: dict[str, Any] | None, **first_class: Any) -> dict[str, Any]:
|
|
62
|
+
"""Merge escape-hatch dict with first-class kwargs (explicit values win)."""
|
|
63
|
+
merged = dict(base or {})
|
|
64
|
+
for key, value in first_class.items():
|
|
65
|
+
if value is not None:
|
|
66
|
+
merged[key] = value
|
|
67
|
+
return merged
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_DEFAULT_ALLOWED_EXTENSIONS: list[str] = ['.js', '.html', '.txt', '.htm', '.xhtml', '.vue']
|
|
71
|
+
_DEFAULT_CMDLINE_ARGS: list[str] = ['--disable-http-cache']
|
|
72
|
+
_JS_NAME_RE = rgx.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
|
|
73
|
+
_this_module = sys.modules[__name__]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Public functions
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def expose(name_or_function: Callable[..., Any] | None = None) -> Callable[..., Any]:
|
|
80
|
+
"""Decorator to expose Python callables via Glue's JavaScript API.
|
|
81
|
+
|
|
82
|
+
When an exposed function is called, a callback function can be passed
|
|
83
|
+
immediately afterwards. This callback will be called asynchronously with
|
|
84
|
+
the return value (possibly `None`) when the Python function has finished
|
|
85
|
+
executing.
|
|
86
|
+
|
|
87
|
+
Blocking calls to the exposed function from the JavaScript side are only
|
|
88
|
+
possible using the :code:`await` keyword inside an :code:`async function`.
|
|
89
|
+
These still have to make a call to the response, i.e.
|
|
90
|
+
:code:`await glue.py_random()();` inside an :code:`async function` will work,
|
|
91
|
+
but just :code:`await glue.py_random();` will not.
|
|
92
|
+
|
|
93
|
+
:Example:
|
|
94
|
+
|
|
95
|
+
In Python do:
|
|
96
|
+
|
|
97
|
+
.. code-block:: python
|
|
98
|
+
|
|
99
|
+
@expose
|
|
100
|
+
def say_hello_py(name: str = 'You') -> None:
|
|
101
|
+
print(f'{name} said hello from the JavaScript world!')
|
|
102
|
+
|
|
103
|
+
In JavaScript do:
|
|
104
|
+
|
|
105
|
+
.. code-block:: javascript
|
|
106
|
+
|
|
107
|
+
glue.say_hello_py('Alice')();
|
|
108
|
+
|
|
109
|
+
Expected output on the Python console::
|
|
110
|
+
|
|
111
|
+
Alice said hello from the JavaScript world!
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
# Deal with '@glue.expose()' - treat as '@glue.expose'
|
|
115
|
+
if name_or_function is None:
|
|
116
|
+
return expose
|
|
117
|
+
|
|
118
|
+
if isinstance(name_or_function, str): # Called as '@glue.expose("my_name")'
|
|
119
|
+
name = name_or_function
|
|
120
|
+
|
|
121
|
+
def decorator(function: Callable[..., Any]) -> Any:
|
|
122
|
+
_expose(name, function)
|
|
123
|
+
return function
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
126
|
+
else:
|
|
127
|
+
function = name_or_function
|
|
128
|
+
_expose(function.__name__, function)
|
|
129
|
+
return function
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# PyParsing grammar for parsing exposed functions in JavaScript code
|
|
133
|
+
# Examples: `glue.expose(w, "func_name")`, `glue.expose(func_name)`, `glue.expose((function (e){}), "func_name")`
|
|
134
|
+
EXPOSED_JS_FUNCTIONS: pp.ZeroOrMore = pp.ZeroOrMore(
|
|
135
|
+
pp.Suppress(
|
|
136
|
+
pp.SkipTo(pp.Literal('glue.expose('))
|
|
137
|
+
+ pp.Literal('glue.expose(')
|
|
138
|
+
+ pp.Optional(
|
|
139
|
+
pp.Or([pp.nested_expr(), pp.Word(pp.printables, exclude_chars=',')]) + pp.Literal(',')
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
+ pp.Suppress(pp.Regex(r'["\']?'))
|
|
143
|
+
+ pp.Word(pp.printables, exclude_chars='"\')')
|
|
144
|
+
+ pp.Suppress(pp.Regex(r'["\']?\s*\)')),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def init(
|
|
149
|
+
path: str = 'ui',
|
|
150
|
+
allowed_extensions: list[str] | None = None,
|
|
151
|
+
js_result_timeout: int = 10000,
|
|
152
|
+
app_name: str | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Initialise Glue.
|
|
155
|
+
|
|
156
|
+
This function should be called before :func:`start()` to initialise the
|
|
157
|
+
parameters for the web interface, such as the path to the files to be
|
|
158
|
+
served.
|
|
159
|
+
|
|
160
|
+
:param path: Sets the path on the filesystem where files to be served to
|
|
161
|
+
the browser are located. *Default:* :file:`ui`.
|
|
162
|
+
:param allowed_extensions: A list of filename extensions which will be
|
|
163
|
+
parsed for exposed glue functions which should be callable from python.
|
|
164
|
+
Files with extensions not in *allowed_extensions* will still be served,
|
|
165
|
+
but any JavaScript functions, even if marked as exposed, will not be
|
|
166
|
+
accessible from python.
|
|
167
|
+
*Default:* :code:`['.js', '.html', '.txt', '.htm', '.xhtml', '.vue']`.
|
|
168
|
+
:param js_result_timeout: How long Glue should be waiting to register the
|
|
169
|
+
results from a call to Glue's JavaScript API before before timing out.
|
|
170
|
+
*Default:* :code:`10000` milliseconds.
|
|
171
|
+
:param app_name: Optional. When set, unlocks the default user settings
|
|
172
|
+
path :file:`~/.{app_name}/{app_name}.json` for :func:`settings` /
|
|
173
|
+
:func:`save_settings`. Does not create any files. *Default:* `None`.
|
|
174
|
+
"""
|
|
175
|
+
global root_path, _js_functions, _js_result_timeout
|
|
176
|
+
root_path = _get_real_path(path)
|
|
177
|
+
|
|
178
|
+
if allowed_extensions is None:
|
|
179
|
+
allowed_extensions = list(_DEFAULT_ALLOWED_EXTENSIONS)
|
|
180
|
+
|
|
181
|
+
js_functions = set()
|
|
182
|
+
for root, _, filenames in os.walk(root_path):
|
|
183
|
+
for name in filenames:
|
|
184
|
+
if not any(name.endswith(ext) for ext in allowed_extensions):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with open(os.path.join(root, name), encoding='utf-8') as file:
|
|
189
|
+
contents = file.read()
|
|
190
|
+
expose_calls = set()
|
|
191
|
+
matches = EXPOSED_JS_FUNCTIONS.parse_string(contents).as_list()
|
|
192
|
+
for expose_call in matches:
|
|
193
|
+
expose_calls.add(_validate_js_name(expose_call))
|
|
194
|
+
js_functions.update(expose_calls)
|
|
195
|
+
except UnicodeDecodeError:
|
|
196
|
+
pass # Malformed file probably
|
|
197
|
+
|
|
198
|
+
_js_functions = list(js_functions)
|
|
199
|
+
for js_function in _js_functions:
|
|
200
|
+
_mock_js_function(js_function)
|
|
201
|
+
|
|
202
|
+
_js_result_timeout = js_result_timeout
|
|
203
|
+
_settings.configure(app_name)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def settings(path: str | None = None) -> dict[str, Any]:
|
|
207
|
+
"""Load app settings JSON as a plain dict.
|
|
208
|
+
|
|
209
|
+
With no *path*, uses :file:`~/.{app_name}/{app_name}.json` (requires
|
|
210
|
+
:code:`app_name` on :func:`init`). Missing file returns :code:`{}` and
|
|
211
|
+
does not create anything on disk. Pass an explicit *path* to load any
|
|
212
|
+
JSON file (works without :code:`app_name`).
|
|
213
|
+
|
|
214
|
+
Mutate the dict as usual, then :func:`save_settings` to persist.
|
|
215
|
+
"""
|
|
216
|
+
return _settings.load(path)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def save_settings(data: dict[str, Any], path: str | None = None) -> str:
|
|
220
|
+
"""Write settings dict as JSON. Creates parent directories on first save.
|
|
221
|
+
|
|
222
|
+
With no *path*, writes to the last path used by :func:`settings`, or the
|
|
223
|
+
default path when :code:`app_name` is set. Returns the path written.
|
|
224
|
+
"""
|
|
225
|
+
return str(_settings.save(data, path))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def settings_path() -> str:
|
|
229
|
+
"""Return the default settings file path (requires :code:`app_name`)."""
|
|
230
|
+
return str(_settings.settings_path())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def start(
|
|
234
|
+
*start_urls: str,
|
|
235
|
+
mode: str | None = 'auto',
|
|
236
|
+
host: str = 'localhost',
|
|
237
|
+
port: int = 8000,
|
|
238
|
+
block: bool = True,
|
|
239
|
+
jinja_templates: str | None = None,
|
|
240
|
+
cmdline_args: list[str] | None = None,
|
|
241
|
+
size: tuple[int, int] | None = None,
|
|
242
|
+
position: tuple[int, int] | None = None,
|
|
243
|
+
geometry: dict[str, WindowGeometryT] | None = None,
|
|
244
|
+
close_callback: Callable[..., Any] | None = None,
|
|
245
|
+
app_mode: bool = True,
|
|
246
|
+
all_interfaces: bool = False,
|
|
247
|
+
disable_cache: bool = True,
|
|
248
|
+
default_path: str = 'index.html',
|
|
249
|
+
app: btl.Bottle | None = None,
|
|
250
|
+
shutdown_delay: float = 1.0,
|
|
251
|
+
title: str | None = None,
|
|
252
|
+
resizable: bool = True,
|
|
253
|
+
frameless: bool | None = None,
|
|
254
|
+
easy_drag: bool | None = None,
|
|
255
|
+
shadow: bool | None = None,
|
|
256
|
+
debug: bool | None = None,
|
|
257
|
+
confirm_close: bool | None = None,
|
|
258
|
+
fullscreen: bool | None = None,
|
|
259
|
+
minimized: bool | None = None,
|
|
260
|
+
maximized: bool | None = None,
|
|
261
|
+
on_top: bool | None = None,
|
|
262
|
+
min_size: tuple[int, int] | None = None,
|
|
263
|
+
icon: str | None = None,
|
|
264
|
+
gui: str | None = None,
|
|
265
|
+
menu: list[Any] | None = None,
|
|
266
|
+
webview_options: dict[str, Any] | None = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Start the Glue app.
|
|
269
|
+
|
|
270
|
+
Suppose you put all the frontend files in a directory called
|
|
271
|
+
:file:`ui`, including your start page :file:`index.html`, then the app
|
|
272
|
+
is started like this:
|
|
273
|
+
|
|
274
|
+
.. code-block:: python
|
|
275
|
+
|
|
276
|
+
import glue
|
|
277
|
+
glue.init()
|
|
278
|
+
glue.start()
|
|
279
|
+
|
|
280
|
+
This will start a webserver on the default settings
|
|
281
|
+
(http://localhost:8000) and open a native window (or browser) to
|
|
282
|
+
http://localhost:8000/index.html. Pass one or more page paths to open
|
|
283
|
+
something else, e.g. :code:`glue.start('main.html')`.
|
|
284
|
+
|
|
285
|
+
By default (:code:`mode='auto'`), Glue prefers **PyWebView** for a real
|
|
286
|
+
desktop window (menus, minimize/maximize/close under your control), then
|
|
287
|
+
falls back to Chrome/Chromium in app mode, then Microsoft Edge on Windows
|
|
288
|
+
only. Use :code:`block=False` with :code:`mode='auto'` to skip PyWebView
|
|
289
|
+
(its GUI loop must own the main thread) and launch Chrome/Edge instead.
|
|
290
|
+
|
|
291
|
+
:param start_urls: One or more relative page paths to open. *Default:*
|
|
292
|
+
:code:`('index.html',)`.
|
|
293
|
+
:param mode: Window host selection. :code:`'auto'` (default) tries
|
|
294
|
+
PyWebView, then Chrome/Chromium, then Edge on Windows. Force
|
|
295
|
+
:code:`'webview'`, :code:`'chrome'`, or
|
|
296
|
+
:code:`'edge'`; use :code:`'custom'` with :code:`cmdline_args` as a
|
|
297
|
+
full :func:`subprocess.Popen` argv list; or :code:`None` for no window.
|
|
298
|
+
:param host: Hostname used for Bottle server. *Default:*
|
|
299
|
+
:code:`'localhost'`.
|
|
300
|
+
:param port: Port used for Bottle server. Use :code:`0` for port to be
|
|
301
|
+
picked automatically. *Default:* :code:`8000`.
|
|
302
|
+
:param block: Whether the call to :func:`start()` blocks the calling
|
|
303
|
+
thread. *Default:* `True`. PyWebView requires :code:`True` when used
|
|
304
|
+
as the window host.
|
|
305
|
+
:param jinja_templates: Folder for :mod:`jinja2` templates, e.g.
|
|
306
|
+
:file:`my_templates`. *Default:* `None`.
|
|
307
|
+
:param cmdline_args: Extra flags for Chrome/Edge (appended to the browser
|
|
308
|
+
command). With :code:`mode='custom'`, this is the **full** argv passed
|
|
309
|
+
to :func:`subprocess.Popen` (executable + args). Example for Chrome:
|
|
310
|
+
:code:`glue.start('main.html', mode='chrome', port=8080,
|
|
311
|
+
cmdline_args=['--start-fullscreen', '--browser-startup-dialog'])`.
|
|
312
|
+
*Default:* :code:`['--disable-http-cache']`.
|
|
313
|
+
:param size: Tuple specifying the (width, height) of the main window in
|
|
314
|
+
pixels. Applied on PyWebView via window kwargs and on Chrome/Edge via
|
|
315
|
+
:code:`window.resizeTo` in :file:`/glue.js`. *Default:* `None`
|
|
316
|
+
(Glue uses :code:`(1280, 720)`).
|
|
317
|
+
:param position: Tuple specifying the (left, top) position of the main
|
|
318
|
+
window in pixels. Chrome/Edge use :code:`window.moveTo` in
|
|
319
|
+
:file:`/glue.js`. *Default*: `None` (host chooses, usually centered).
|
|
320
|
+
:param geometry: Per-page size/position map (page path →
|
|
321
|
+
:code:`{'size': (w, h), 'position': (x, y)}`, either key optional).
|
|
322
|
+
Applied on PyWebView and via :file:`/glue.js` for Chrome/Edge.
|
|
323
|
+
*Default:* :code:`{}`.
|
|
324
|
+
:param close_callback: A lambda or function that is called when a websocket
|
|
325
|
+
or window closes (i.e. when the user closes the window). It should take
|
|
326
|
+
two arguments: a string which is the relative path of the page that
|
|
327
|
+
just closed, and a list of the other websockets that are still open.
|
|
328
|
+
*Default:* `None`.
|
|
329
|
+
:param app_mode: Whether to run Edge/Chrome in App Mode (:code:`--app`).
|
|
330
|
+
*Default:* :code:`True`. Ignored for PyWebView.
|
|
331
|
+
:param all_interfaces: Whether to allow the :mod:`bottle` server to listen
|
|
332
|
+
for connections on all interfaces (:code:`0.0.0.0`) and accept Glue
|
|
333
|
+
WebSocket clients from non-loopback peers. **Warning:** any client that
|
|
334
|
+
can reach this host can invoke every :func:`expose`d Python function
|
|
335
|
+
over the WebSocket. Use only on trusted networks. When :code:`False`
|
|
336
|
+
(default), non-loopback WebSocket peers are rejected. *Default:*
|
|
337
|
+
:code:`False`.
|
|
338
|
+
:param disable_cache: Sets the no-store response header when serving
|
|
339
|
+
assets.
|
|
340
|
+
:param default_path: The default file to retrieve for the root URL.
|
|
341
|
+
:param app: An instance of :class:`bottle.Bottle` which will be used rather
|
|
342
|
+
than creating a fresh one. This can be used to install middleware on
|
|
343
|
+
the instance before starting Glue, e.g. for session management,
|
|
344
|
+
authentication, etc. If *app* is not a :class:`bottle.Bottle` instance,
|
|
345
|
+
you will need to call :code:`glue.register_glue_routes(app)` on your
|
|
346
|
+
custom app instance.
|
|
347
|
+
:param shutdown_delay: Timer configurable for Glue's shutdown detection
|
|
348
|
+
mechanism, whereby when any websocket closes, it waits *shutdown_delay*
|
|
349
|
+
seconds, and then checks if there are now any websocket connections.
|
|
350
|
+
If not, then Glue closes. In case the user has closed the browser and
|
|
351
|
+
wants to exit the program. *Default:* :code:`1.0` seconds.
|
|
352
|
+
:param title: Window title. Applied to PyWebView's native/in-page chrome
|
|
353
|
+
and, when set, to ``document.title`` via :file:`/glue.js` so Chrome/Edge
|
|
354
|
+
app-mode captions match. When omitted, Chrome/Edge keep each page's
|
|
355
|
+
``<title>``. *Default:* :code:`None` (PyWebView falls back to
|
|
356
|
+
:code:`'Glue'`).
|
|
357
|
+
:param resizable: Whether the user can resize the window (PyWebView).
|
|
358
|
+
*Default:* :code:`True`. Ignored for Chrome/Edge app windows unless
|
|
359
|
+
you pass matching browser flags yourself.
|
|
360
|
+
:param frameless: PyWebView: hide native caption bar (Glue draws an
|
|
361
|
+
in-page title bar). *Default when unset:* :code:`True`.
|
|
362
|
+
:param easy_drag: PyWebView: drag window from anywhere when frameless.
|
|
363
|
+
*Default when unset:* :code:`False` (drag via title-bar region).
|
|
364
|
+
:param shadow: PyWebView window shadow (Windows). *Default when unset:*
|
|
365
|
+
:code:`True` on Windows.
|
|
366
|
+
:param debug: PyWebView: DevTools / browser accelerators. *Default when
|
|
367
|
+
unset:* :code:`False`.
|
|
368
|
+
:param confirm_close: PyWebView: native close confirmation. *Default when
|
|
369
|
+
unset:* :code:`False`.
|
|
370
|
+
:param fullscreen: PyWebView start fullscreen. *Default when unset:*
|
|
371
|
+
:code:`False`.
|
|
372
|
+
:param minimized: PyWebView start minimized. *Default when unset:*
|
|
373
|
+
:code:`False`.
|
|
374
|
+
:param maximized: PyWebView start maximized. *Default when unset:*
|
|
375
|
+
:code:`False`.
|
|
376
|
+
:param on_top: PyWebView always-on-top. *Default when unset:* :code:`False`.
|
|
377
|
+
:param min_size: PyWebView ``(width, height)`` minimum. *Default when
|
|
378
|
+
unset:* :code:`(200, 100)`.
|
|
379
|
+
:param icon: Path to ``.ico`` (Windows) / ``.icns`` (macOS) for the native
|
|
380
|
+
window. *Default when unset:* ``ui/favicon.ico`` if present.
|
|
381
|
+
:param gui: Force PyWebView backend (``edgechromium``, ``qt``, ``gtk``,
|
|
382
|
+
…). *Default when unset:* auto.
|
|
383
|
+
:param menu: PyWebView application :class:`Menu` list. *Default when
|
|
384
|
+
unset:* no menus (``[]``).
|
|
385
|
+
:param webview_options: Escape hatch for other PyWebView
|
|
386
|
+
:func:`webview.create_window` / :func:`webview.start` kwargs (e.g.
|
|
387
|
+
:code:`private_mode`, :code:`transparent`). Explicit first-class
|
|
388
|
+
kwargs above win over the same key here. *Default:* :code:`None`.
|
|
389
|
+
"""
|
|
390
|
+
if cmdline_args is None:
|
|
391
|
+
cmdline_args = list(_DEFAULT_CMDLINE_ARGS)
|
|
392
|
+
if geometry is None:
|
|
393
|
+
geometry = {}
|
|
394
|
+
webview_options = _merge_webview_options(
|
|
395
|
+
webview_options,
|
|
396
|
+
frameless=frameless,
|
|
397
|
+
easy_drag=easy_drag,
|
|
398
|
+
shadow=shadow,
|
|
399
|
+
debug=debug,
|
|
400
|
+
confirm_close=confirm_close,
|
|
401
|
+
fullscreen=fullscreen,
|
|
402
|
+
minimized=minimized,
|
|
403
|
+
maximized=maximized,
|
|
404
|
+
on_top=on_top,
|
|
405
|
+
min_size=min_size,
|
|
406
|
+
icon=icon,
|
|
407
|
+
gui=gui,
|
|
408
|
+
menu=menu,
|
|
409
|
+
)
|
|
410
|
+
if app is None:
|
|
411
|
+
app = btl.default_app()
|
|
412
|
+
if not start_urls:
|
|
413
|
+
start_urls = ('index.html',)
|
|
414
|
+
|
|
415
|
+
if all_interfaces:
|
|
416
|
+
warnings.warn(
|
|
417
|
+
'all_interfaces=True binds on 0.0.0.0 and allows non-loopback '
|
|
418
|
+
'Glue WebSocket clients; any client that can reach this host can '
|
|
419
|
+
'call @glue.expose Python functions. Use only on trusted networks.',
|
|
420
|
+
UserWarning,
|
|
421
|
+
stacklevel=2,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Omit / None → DEFAULT_WINDOW_SIZE (Chrome/Edge resizeTo + PyWebView create_window).
|
|
425
|
+
if size is None:
|
|
426
|
+
import glue.webview as webview
|
|
427
|
+
|
|
428
|
+
size = webview.DEFAULT_WINDOW_SIZE
|
|
429
|
+
|
|
430
|
+
_start_args.update(
|
|
431
|
+
{
|
|
432
|
+
'mode': mode,
|
|
433
|
+
'host': host,
|
|
434
|
+
'port': port,
|
|
435
|
+
'block': block,
|
|
436
|
+
'jinja_templates': jinja_templates,
|
|
437
|
+
'cmdline_args': cmdline_args,
|
|
438
|
+
'size': size,
|
|
439
|
+
'position': position,
|
|
440
|
+
'geometry': geometry,
|
|
441
|
+
'title': title,
|
|
442
|
+
'resizable': resizable,
|
|
443
|
+
'webview_options': webview_options,
|
|
444
|
+
'close_callback': close_callback,
|
|
445
|
+
'app_mode': app_mode,
|
|
446
|
+
'all_interfaces': all_interfaces,
|
|
447
|
+
'disable_cache': disable_cache,
|
|
448
|
+
'default_path': default_path,
|
|
449
|
+
'app': app,
|
|
450
|
+
'shutdown_delay': shutdown_delay,
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if _start_args['port'] == 0:
|
|
455
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
456
|
+
sock.bind(('localhost', 0))
|
|
457
|
+
_start_args['port'] = sock.getsockname()[1]
|
|
458
|
+
sock.close()
|
|
459
|
+
|
|
460
|
+
if _start_args['jinja_templates'] is not None:
|
|
461
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
462
|
+
|
|
463
|
+
if not isinstance(_start_args['jinja_templates'], str):
|
|
464
|
+
raise TypeError("'jinja_templates' start_arg/option must be of type str")
|
|
465
|
+
templates_path = os.path.join(root_path, _start_args['jinja_templates'])
|
|
466
|
+
_start_args['jinja_env'] = Environment(
|
|
467
|
+
loader=FileSystemLoader(templates_path), autoescape=select_autoescape(['html', 'xml'])
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# verify shutdown_delay is correct value
|
|
471
|
+
if not isinstance(_start_args['shutdown_delay'], (int, float)):
|
|
472
|
+
raise ValueError(
|
|
473
|
+
'`shutdown_delay` must be a number, got a {}'.format(
|
|
474
|
+
type(_start_args['shutdown_delay'])
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def run_lambda() -> None:
|
|
479
|
+
if _start_args['all_interfaces'] is True:
|
|
480
|
+
HOST = '0.0.0.0'
|
|
481
|
+
else:
|
|
482
|
+
if not isinstance(_start_args['host'], str):
|
|
483
|
+
raise TypeError("'host' start_arg/option must be of type str")
|
|
484
|
+
HOST = _start_args['host']
|
|
485
|
+
|
|
486
|
+
app = _start_args['app']
|
|
487
|
+
|
|
488
|
+
if isinstance(app, btl.Bottle):
|
|
489
|
+
register_glue_routes(app)
|
|
490
|
+
else:
|
|
491
|
+
register_glue_routes(btl.default_app())
|
|
492
|
+
|
|
493
|
+
btl.run(
|
|
494
|
+
host=HOST,
|
|
495
|
+
port=_start_args['port'],
|
|
496
|
+
server=wbs.GeventWebSocketServer,
|
|
497
|
+
quiet=True,
|
|
498
|
+
app=app,
|
|
499
|
+
) # Always returns None
|
|
500
|
+
|
|
501
|
+
def _wait_for_server(*, cooperative: bool = True) -> None:
|
|
502
|
+
"""Wait until the server is accepting connections, then open the window.
|
|
503
|
+
|
|
504
|
+
When the Bottle server runs as a gevent greenlet on this hub, use
|
|
505
|
+
cooperative :func:`gevent.sleep` so the server can accept during the
|
|
506
|
+
wait. When it runs in another OS thread (PyWebView), use
|
|
507
|
+
:func:`time.sleep` so we do not depend on this thread's hub.
|
|
508
|
+
"""
|
|
509
|
+
sleep = gvt.sleep if cooperative else time.sleep
|
|
510
|
+
host = _start_args['host'] if not _start_args['all_interfaces'] else '127.0.0.1'
|
|
511
|
+
if not isinstance(host, str):
|
|
512
|
+
host = '127.0.0.1'
|
|
513
|
+
# Bottle on 0.0.0.0 is reached via loopback for the readiness probe
|
|
514
|
+
if host in ('0.0.0.0', '::'):
|
|
515
|
+
host = '127.0.0.1'
|
|
516
|
+
port = _start_args['port']
|
|
517
|
+
if not isinstance(port, int):
|
|
518
|
+
raise TypeError("'port' start_arg/option must be of type int")
|
|
519
|
+
ready = False
|
|
520
|
+
for _ in range(100):
|
|
521
|
+
try:
|
|
522
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
523
|
+
sock.settimeout(0.2)
|
|
524
|
+
sock.connect((host, port))
|
|
525
|
+
sock.close()
|
|
526
|
+
ready = True
|
|
527
|
+
break
|
|
528
|
+
except OSError:
|
|
529
|
+
sleep(0.05)
|
|
530
|
+
if not ready:
|
|
531
|
+
raise RuntimeError(
|
|
532
|
+
'Glue server did not become ready on %s:%s '
|
|
533
|
+
'(not accepting connections after startup wait)' % (host, port)
|
|
534
|
+
)
|
|
535
|
+
show(*start_urls)
|
|
536
|
+
|
|
537
|
+
def _should_run_server_in_thread() -> bool:
|
|
538
|
+
"""PyWebView blocks the main thread; a gevent server greenlet would starve."""
|
|
539
|
+
mode = _start_args.get('mode')
|
|
540
|
+
if mode in brw.WEBVIEW_MODES:
|
|
541
|
+
return bool(_start_args.get('block', True))
|
|
542
|
+
if mode == 'auto':
|
|
543
|
+
import glue.webview as webview
|
|
544
|
+
|
|
545
|
+
return webview.should_try(_start_args)
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
# Start the webserver first, then open the window once listening (avoids race).
|
|
549
|
+
# PyWebView's GUI loop blocks the main thread, so the Bottle server must run
|
|
550
|
+
# in a real OS thread in that case — not a greenlet on the same hub.
|
|
551
|
+
if _should_run_server_in_thread():
|
|
552
|
+
server_thread = threading.Thread(target=run_lambda, name='glue-bottle', daemon=True)
|
|
553
|
+
server_thread.start()
|
|
554
|
+
_wait_for_server(cooperative=False)
|
|
555
|
+
# GUI closed: exit so we do not hang joining a daemon server thread.
|
|
556
|
+
if brw.webview_session_completed():
|
|
557
|
+
sys.exit(0)
|
|
558
|
+
if _start_args['block']:
|
|
559
|
+
# PyWebView failed over to Chrome/Edge; wait until websockets exit.
|
|
560
|
+
server_thread.join()
|
|
561
|
+
else:
|
|
562
|
+
server_greenlet = spawn(run_lambda)
|
|
563
|
+
_wait_for_server(cooperative=True)
|
|
564
|
+
if brw.webview_session_completed():
|
|
565
|
+
sys.exit(0)
|
|
566
|
+
if _start_args['block']:
|
|
567
|
+
server_greenlet.join()
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def get_webview_windows() -> list[Any]:
|
|
571
|
+
"""Return PyWebView window instances for the current Glue session.
|
|
572
|
+
|
|
573
|
+
Empty when Chrome/Edge (or no window) is used. Useful for menus, title,
|
|
574
|
+
and other native window control via the PyWebView API.
|
|
575
|
+
"""
|
|
576
|
+
import glue.webview as webview
|
|
577
|
+
|
|
578
|
+
return webview.get_windows()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def show(*start_urls: str) -> None:
|
|
582
|
+
"""Show the specified URL(s) in the browser.
|
|
583
|
+
|
|
584
|
+
Suppose you have two files in your :file:`ui` folder. The file
|
|
585
|
+
:file:`hello.html` regularly includes :file:`glue.js` and provides
|
|
586
|
+
interactivity, and the file :file:`goodbye.html` does not include
|
|
587
|
+
:file:`glue.js` and simply provides plain HTML content not reliant on Glue.
|
|
588
|
+
|
|
589
|
+
First, we define a callback function to be called when the browser
|
|
590
|
+
window is closed:
|
|
591
|
+
|
|
592
|
+
.. code-block:: python
|
|
593
|
+
|
|
594
|
+
def last_calls():
|
|
595
|
+
glue.show('goodbye.html')
|
|
596
|
+
|
|
597
|
+
Now we initialise and start Glue, with a :code:`close_callback` to our
|
|
598
|
+
function:
|
|
599
|
+
|
|
600
|
+
.. code-block:: python
|
|
601
|
+
|
|
602
|
+
glue.init()
|
|
603
|
+
glue.start('hello.html', mode='auto', close_callback=last_calls)
|
|
604
|
+
|
|
605
|
+
When the websocket from :file:`hello.html` is closed (e.g. because the
|
|
606
|
+
user closed the browser window), Glue will wait *shutdown_delay* seconds
|
|
607
|
+
(by default 1 second), then call our :code:`last_calls()` function, which
|
|
608
|
+
opens another window with the :file:`goodbye.html` shown before our Glue app
|
|
609
|
+
terminates.
|
|
610
|
+
|
|
611
|
+
:param start_urls: One or more URLs to be opened.
|
|
612
|
+
"""
|
|
613
|
+
brw.open(list(start_urls), _start_args)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def sleep(seconds: int | float) -> None:
|
|
617
|
+
"""A non-blocking sleep call compatible with the Gevent event loop.
|
|
618
|
+
|
|
619
|
+
.. note::
|
|
620
|
+
While this function simply wraps :func:`gevent.sleep()`, it is better
|
|
621
|
+
to call :func:`glue.sleep()` in your Glue app, as this will ensure future
|
|
622
|
+
compatibility in case the implementation of Glue should change in some
|
|
623
|
+
respect.
|
|
624
|
+
|
|
625
|
+
:param seconds: The number of seconds to sleep.
|
|
626
|
+
"""
|
|
627
|
+
gvt.sleep(seconds)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def spawn(function: Callable[..., Any], *args: Any, **kwargs: Any) -> gvt.Greenlet:
|
|
631
|
+
"""Spawn a new Greenlet.
|
|
632
|
+
|
|
633
|
+
Calling this function will spawn a new :class:`gevent.Greenlet` running
|
|
634
|
+
*function* asynchronously.
|
|
635
|
+
|
|
636
|
+
.. caution::
|
|
637
|
+
If you spawn your own Greenlets to run in addition to those spawned by
|
|
638
|
+
Glue's internal core functionality, you will have to ensure that those
|
|
639
|
+
Greenlets will terminate as appropriate (either by returning or by
|
|
640
|
+
being killed via Gevent's kill mechanism), otherwise your app may not
|
|
641
|
+
terminate correctly when Glue itself terminates.
|
|
642
|
+
|
|
643
|
+
:param function: The function to be called and run as the Greenlet.
|
|
644
|
+
:param *args: Any positional arguments that should be passed to *function*.
|
|
645
|
+
:param **kwargs: Any key-word arguments that should be passed to
|
|
646
|
+
*function*.
|
|
647
|
+
"""
|
|
648
|
+
return gvt.spawn(function, *args, **kwargs)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# Bottle Routes
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _favicon_href() -> str | None:
|
|
655
|
+
"""URL for ``ui/favicon.ico`` with mtime cache-bust (Chrome favicon DB ignores Cache-Control)."""
|
|
656
|
+
import glue.webview as webview
|
|
657
|
+
|
|
658
|
+
path = webview._default_favicon_path()
|
|
659
|
+
if not path:
|
|
660
|
+
return None
|
|
661
|
+
try:
|
|
662
|
+
version = int(os.path.getmtime(path))
|
|
663
|
+
except OSError:
|
|
664
|
+
version = 0
|
|
665
|
+
return '/favicon.ico?v=%d' % version
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
_ICON_LINK_RE = rgx.compile(
|
|
669
|
+
r'<link\b[^>]*\brel\s*=\s*["\'](?:shortcut\s+)?icon["\']',
|
|
670
|
+
rgx.IGNORECASE,
|
|
671
|
+
)
|
|
672
|
+
_HEAD_RE = rgx.compile(r'<head\b[^>]*>', rgx.IGNORECASE)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _inject_html_favicon(html: str) -> str:
|
|
676
|
+
"""Insert a favicon ``<link>`` after ``<head>`` when ``ui/favicon.ico`` exists.
|
|
677
|
+
|
|
678
|
+
Chromium ``--app`` windows read the icon from the first HTML document. Doing
|
|
679
|
+
this server-side (not in ``glue.js``) puts the tag in the bytes before render.
|
|
680
|
+
"""
|
|
681
|
+
href = _favicon_href()
|
|
682
|
+
if not href or _ICON_LINK_RE.search(html):
|
|
683
|
+
return html
|
|
684
|
+
match = _HEAD_RE.search(html)
|
|
685
|
+
if not match:
|
|
686
|
+
return html
|
|
687
|
+
link = '<link rel="icon" type="image/x-icon" href="%s">' % href
|
|
688
|
+
return html[: match.end()] + '\n ' + link + html[match.end() :]
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _response_body_text(response: btl.Response) -> str | None:
|
|
692
|
+
"""Best-effort decode of a Bottle response body as text."""
|
|
693
|
+
body = getattr(response, 'body', None)
|
|
694
|
+
if body is None:
|
|
695
|
+
return None
|
|
696
|
+
if isinstance(body, str):
|
|
697
|
+
return body
|
|
698
|
+
if isinstance(body, bytes):
|
|
699
|
+
try:
|
|
700
|
+
return body.decode('utf-8')
|
|
701
|
+
except UnicodeDecodeError:
|
|
702
|
+
return None
|
|
703
|
+
read = getattr(body, 'read', None)
|
|
704
|
+
if callable(read):
|
|
705
|
+
data = read()
|
|
706
|
+
if isinstance(data, str):
|
|
707
|
+
return data
|
|
708
|
+
if isinstance(data, bytes):
|
|
709
|
+
try:
|
|
710
|
+
return data.decode('utf-8')
|
|
711
|
+
except UnicodeDecodeError:
|
|
712
|
+
return None
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _maybe_inject_favicon_html(path: str, response: btl.Response) -> btl.Response:
|
|
717
|
+
"""Rewrite HTML responses to include ``/favicon.ico`` when present on disk."""
|
|
718
|
+
lower = path.lower().split('?', 1)[0]
|
|
719
|
+
if not (lower.endswith('.html') or lower.endswith('.htm')):
|
|
720
|
+
return response
|
|
721
|
+
status = getattr(response, 'status_code', None)
|
|
722
|
+
if status is None:
|
|
723
|
+
status = int(str(getattr(response, 'status', '200')).split()[0])
|
|
724
|
+
if int(status) >= 400:
|
|
725
|
+
return response
|
|
726
|
+
text = _response_body_text(response)
|
|
727
|
+
if text is None:
|
|
728
|
+
return response
|
|
729
|
+
# Always rebuild: static_file bodies are file-like and consumed by the read above.
|
|
730
|
+
injected = _inject_html_favicon(text)
|
|
731
|
+
out = btl.HTTPResponse(injected, status=status)
|
|
732
|
+
content_type = response.get_header('Content-Type') or 'text/html; charset=UTF-8'
|
|
733
|
+
out.set_header('Content-Type', content_type)
|
|
734
|
+
for name, value in response.headers.items():
|
|
735
|
+
if name.lower() in ('content-type', 'content-length'):
|
|
736
|
+
continue
|
|
737
|
+
out.set_header(name, value)
|
|
738
|
+
return out
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _glue() -> str:
|
|
742
|
+
import glue.webview as webview
|
|
743
|
+
|
|
744
|
+
start_geometry = {
|
|
745
|
+
'default': {'size': _start_args['size'], 'position': _start_args['position']},
|
|
746
|
+
'pages': _start_args['geometry'],
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
page = _glue_js.replace(
|
|
750
|
+
'/** _py_functions **/', '_py_functions: %s,' % _safe_json(list(_exposed_functions.keys()))
|
|
751
|
+
)
|
|
752
|
+
page = page.replace(
|
|
753
|
+
'/** _start_geometry **/', '_start_geometry: %s,' % _safe_json(start_geometry)
|
|
754
|
+
)
|
|
755
|
+
page = page.replace('/** _webview **/', '_webview: %s,' % _safe_json(webview.titlebar_config()))
|
|
756
|
+
window_title = _start_args.get('title')
|
|
757
|
+
if not isinstance(window_title, str) or not window_title.strip():
|
|
758
|
+
window_title = None
|
|
759
|
+
else:
|
|
760
|
+
window_title = window_title.strip()
|
|
761
|
+
page = page.replace(
|
|
762
|
+
'/** _window_title **/', '_window_title: %s,' % _safe_json(window_title)
|
|
763
|
+
)
|
|
764
|
+
page = page.replace(
|
|
765
|
+
'/** _favicon_href **/', '_favicon_href: %s,' % _safe_json(_favicon_href())
|
|
766
|
+
)
|
|
767
|
+
btl.response.content_type = 'application/javascript'
|
|
768
|
+
_set_response_headers(btl.response)
|
|
769
|
+
return page
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _root() -> btl.Response:
|
|
773
|
+
if not isinstance(_start_args['default_path'], str):
|
|
774
|
+
raise TypeError("'default_path' start_arg/option must be of type str")
|
|
775
|
+
return _static(_start_args['default_path'])
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _static(path: str) -> btl.Response:
|
|
779
|
+
response = None
|
|
780
|
+
if 'jinja_env' in _start_args and 'jinja_templates' in _start_args:
|
|
781
|
+
if not isinstance(_start_args['jinja_templates'], str):
|
|
782
|
+
raise TypeError("'jinja_templates' start_arg/option must be of type str")
|
|
783
|
+
template_prefix = _start_args['jinja_templates'] + '/'
|
|
784
|
+
if path.startswith(template_prefix):
|
|
785
|
+
n = len(template_prefix)
|
|
786
|
+
template = _start_args['jinja_env'].get_template(path[n:])
|
|
787
|
+
response = btl.HTTPResponse(template.render())
|
|
788
|
+
|
|
789
|
+
if response is None:
|
|
790
|
+
response = btl.static_file(path, root=root_path)
|
|
791
|
+
|
|
792
|
+
response = _maybe_inject_favicon_html(path, response)
|
|
793
|
+
_set_response_headers(response)
|
|
794
|
+
return response
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _is_loopback_addr(addr: str | None) -> bool:
|
|
798
|
+
"""True if *addr* is a loopback client address (IPv4/IPv6 / mapped)."""
|
|
799
|
+
if not addr:
|
|
800
|
+
return False
|
|
801
|
+
a = addr.strip().lower()
|
|
802
|
+
if a in ('127.0.0.1', '::1', 'localhost'):
|
|
803
|
+
return True
|
|
804
|
+
return bool(a.startswith('::ffff:') and a.rsplit(':', 1)[-1] == '127.0.0.1')
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _websocket(ws: WebSocketT) -> None:
|
|
808
|
+
global _websockets
|
|
809
|
+
|
|
810
|
+
# Default: only loopback may open the Glue bridge (remote needs all_interfaces=True).
|
|
811
|
+
if not _start_args.get('all_interfaces'):
|
|
812
|
+
peer = btl.request.environ.get('REMOTE_ADDR')
|
|
813
|
+
if not _is_loopback_addr(peer if isinstance(peer, str) else None):
|
|
814
|
+
try:
|
|
815
|
+
ws.close()
|
|
816
|
+
except Exception:
|
|
817
|
+
pass
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
for js_function in _js_functions:
|
|
821
|
+
_import_js_function(js_function)
|
|
822
|
+
|
|
823
|
+
page = btl.request.query.page
|
|
824
|
+
if page not in _mock_queue_done:
|
|
825
|
+
for call in _mock_queue:
|
|
826
|
+
_repeated_send(ws, _safe_json(call))
|
|
827
|
+
_mock_queue_done.add(page)
|
|
828
|
+
|
|
829
|
+
_websockets += [(page, ws)]
|
|
830
|
+
|
|
831
|
+
while True:
|
|
832
|
+
msg = ws.receive()
|
|
833
|
+
if msg is not None:
|
|
834
|
+
message = jsn.loads(msg)
|
|
835
|
+
spawn(_process_message, message, ws)
|
|
836
|
+
else:
|
|
837
|
+
_websockets.remove((page, ws))
|
|
838
|
+
break
|
|
839
|
+
|
|
840
|
+
_websocket_close(page)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
BOTTLE_ROUTES: dict[str, tuple[Callable[..., Any], dict[Any, Any]]] = {
|
|
844
|
+
'/glue.js': (_glue, dict()),
|
|
845
|
+
'/': (_root, dict()),
|
|
846
|
+
'/<path:path>': (_static, dict()),
|
|
847
|
+
'/glue': (_websocket, dict(apply=[wbs.websocket])),
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def register_glue_routes(app: btl.Bottle) -> None:
|
|
852
|
+
"""Register the required Glue routes with `app`.
|
|
853
|
+
|
|
854
|
+
.. note::
|
|
855
|
+
|
|
856
|
+
:func:`glue.register_glue_routes()` is normally invoked implicitly by
|
|
857
|
+
:func:`glue.start()` and does not need to be called explicitly in most
|
|
858
|
+
cases. Registering the Glue routes explicitly is only needed if you are
|
|
859
|
+
passing something other than an instance of :class:`bottle.Bottle` to
|
|
860
|
+
:func:`glue.start()`.
|
|
861
|
+
|
|
862
|
+
:Example:
|
|
863
|
+
|
|
864
|
+
>>> app = bottle.Bottle()
|
|
865
|
+
>>> glue.register_glue_routes(app)
|
|
866
|
+
>>> middleware = beaker.middleware.SessionMiddleware(app)
|
|
867
|
+
>>> glue.start(app=middleware)
|
|
868
|
+
|
|
869
|
+
"""
|
|
870
|
+
for route_path, route_params in BOTTLE_ROUTES.items():
|
|
871
|
+
route_func, route_kwargs = route_params
|
|
872
|
+
app.route(path=route_path, callback=route_func, **route_kwargs)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# Private functions
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _safe_json(obj: Any) -> str:
|
|
879
|
+
return jsn.dumps(obj, default=lambda o: None)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _repeated_send(ws: WebSocketT, msg: str) -> None:
|
|
883
|
+
for _attempt in range(100):
|
|
884
|
+
try:
|
|
885
|
+
ws.send(msg)
|
|
886
|
+
break
|
|
887
|
+
except Exception:
|
|
888
|
+
sleep(0.001)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _process_message(message: dict[str, Any], ws: WebSocketT) -> None:
|
|
892
|
+
if 'call' in message:
|
|
893
|
+
error_info: dict[str, str] = {}
|
|
894
|
+
return_val: Any = None
|
|
895
|
+
try:
|
|
896
|
+
name = message['name']
|
|
897
|
+
if name not in _exposed_functions:
|
|
898
|
+
raise KeyError('Function %r is not exposed' % (name,))
|
|
899
|
+
return_val = _exposed_functions[name](*message['args'])
|
|
900
|
+
status = 'ok'
|
|
901
|
+
except Exception as e:
|
|
902
|
+
traceback.print_exc() # server-side only — do not send stacks to the client
|
|
903
|
+
return_val = None
|
|
904
|
+
status = 'error'
|
|
905
|
+
error_info['errorText'] = repr(e)
|
|
906
|
+
_repeated_send(
|
|
907
|
+
ws,
|
|
908
|
+
_safe_json(
|
|
909
|
+
{
|
|
910
|
+
'return': message['call'],
|
|
911
|
+
'status': status,
|
|
912
|
+
'value': return_val,
|
|
913
|
+
'error': error_info,
|
|
914
|
+
}
|
|
915
|
+
),
|
|
916
|
+
)
|
|
917
|
+
elif 'return' in message:
|
|
918
|
+
call_id = message['return']
|
|
919
|
+
if call_id in _call_return_callbacks:
|
|
920
|
+
callback, error_callback = _call_return_callbacks.pop(call_id)
|
|
921
|
+
if message['status'] == 'ok':
|
|
922
|
+
callback(message['value'])
|
|
923
|
+
elif message['status'] == 'error' and error_callback is not None:
|
|
924
|
+
err = message.get('error')
|
|
925
|
+
# Wire shape from JS: {'errorText', 'errorTraceback'} or legacy string+stack
|
|
926
|
+
if isinstance(err, dict):
|
|
927
|
+
error_callback(err.get('errorText'), err.get('errorTraceback'))
|
|
928
|
+
else:
|
|
929
|
+
error_callback(err, message.get('stack'))
|
|
930
|
+
else:
|
|
931
|
+
# Sync waiters: unblock even on error (value may be None)
|
|
932
|
+
_call_return_values[call_id] = message.get('value')
|
|
933
|
+
|
|
934
|
+
else:
|
|
935
|
+
print('Invalid message received: ', message)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _get_real_path(path: str) -> str:
|
|
939
|
+
if getattr(sys, 'frozen', False):
|
|
940
|
+
return os.path.join(sys._MEIPASS, path) # type: ignore # sys._MEIPASS is dynamically added by PyInstaller
|
|
941
|
+
else:
|
|
942
|
+
return os.path.abspath(path)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _validate_js_name(name: str) -> str:
|
|
946
|
+
if not _JS_NAME_RE.fullmatch(name):
|
|
947
|
+
raise ValueError(
|
|
948
|
+
'Invalid JavaScript function name from glue.expose(): %r '
|
|
949
|
+
'(expected a Python/JS identifier)' % (name,)
|
|
950
|
+
)
|
|
951
|
+
return name
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _mock_js_function(f: str) -> None:
|
|
955
|
+
name = _validate_js_name(f)
|
|
956
|
+
setattr(_this_module, name, lambda *args, _n=name: _mock_call(_n, args))
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _import_js_function(f: str) -> None:
|
|
960
|
+
name = _validate_js_name(f)
|
|
961
|
+
setattr(_this_module, name, lambda *args, _n=name: _js_call(_n, args))
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _call_object(name: str, args: Any) -> dict[str, Any]:
|
|
965
|
+
global _call_number
|
|
966
|
+
_call_number += 1
|
|
967
|
+
call_id = _call_number + rnd.random()
|
|
968
|
+
return {'call': call_id, 'name': name, 'args': args}
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _mock_call(
|
|
972
|
+
name: str, args: Any
|
|
973
|
+
) -> Callable[[Callable[..., Any] | None, Callable[..., Any] | None], Any]:
|
|
974
|
+
call_object = _call_object(name, args)
|
|
975
|
+
global _mock_queue
|
|
976
|
+
_mock_queue += [call_object]
|
|
977
|
+
return _call_return(call_object)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _js_call(
|
|
981
|
+
name: str, args: Any
|
|
982
|
+
) -> Callable[[Callable[..., Any] | None, Callable[..., Any] | None], Any]:
|
|
983
|
+
"""Call a JS function on the most recently connected Glue page.
|
|
984
|
+
|
|
985
|
+
Multi-window note: Python→JS calls are sent to the last websocket only
|
|
986
|
+
(not broadcast), so return values are unambiguous. Open pages that need
|
|
987
|
+
the call must be that latest connection, or call from that page's JS.
|
|
988
|
+
"""
|
|
989
|
+
call_object = _call_object(name, args)
|
|
990
|
+
if _websockets:
|
|
991
|
+
_, ws = _websockets[-1]
|
|
992
|
+
_repeated_send(ws, _safe_json(call_object))
|
|
993
|
+
return _call_return(call_object)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _call_return(
|
|
997
|
+
call: dict[str, Any],
|
|
998
|
+
) -> Callable[[Callable[..., Any] | None, Callable[..., Any] | None], Any]:
|
|
999
|
+
global _js_result_timeout
|
|
1000
|
+
call_id = call['call']
|
|
1001
|
+
|
|
1002
|
+
def return_func(
|
|
1003
|
+
callback: Callable[..., Any] | None = None, error_callback: Callable[..., Any] | None = None
|
|
1004
|
+
) -> Any:
|
|
1005
|
+
if callback is not None:
|
|
1006
|
+
_call_return_callbacks[call_id] = (callback, error_callback)
|
|
1007
|
+
else:
|
|
1008
|
+
for _w in range(_js_result_timeout):
|
|
1009
|
+
if call_id in _call_return_values:
|
|
1010
|
+
return _call_return_values.pop(call_id)
|
|
1011
|
+
sleep(0.001)
|
|
1012
|
+
|
|
1013
|
+
return return_func
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _expose(name: str, function: Callable[..., Any]) -> None:
|
|
1017
|
+
name = _validate_js_name(name)
|
|
1018
|
+
if name in _exposed_functions:
|
|
1019
|
+
raise ValueError('Already exposed function with name "%s"' % name)
|
|
1020
|
+
_exposed_functions[name] = function
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _detect_shutdown() -> None:
|
|
1024
|
+
if len(_websockets) == 0:
|
|
1025
|
+
sys.exit()
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _websocket_close(page: str) -> None:
|
|
1029
|
+
global _shutdown
|
|
1030
|
+
|
|
1031
|
+
close_callback = _start_args.get('close_callback')
|
|
1032
|
+
|
|
1033
|
+
if close_callback is not None:
|
|
1034
|
+
if not callable(close_callback):
|
|
1035
|
+
raise TypeError("'close_callback' start_arg/option must be callable or None")
|
|
1036
|
+
sockets = [p for _, p in _websockets]
|
|
1037
|
+
close_callback(page, sockets)
|
|
1038
|
+
else:
|
|
1039
|
+
if isinstance(_shutdown, gvt.Greenlet):
|
|
1040
|
+
_shutdown.kill()
|
|
1041
|
+
|
|
1042
|
+
_shutdown = gvt.spawn_later(_start_args['shutdown_delay'], _detect_shutdown)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _set_response_headers(response: btl.Response) -> None:
|
|
1046
|
+
if _start_args['disable_cache']:
|
|
1047
|
+
# https://stackoverflow.com/a/24748094/280852
|
|
1048
|
+
response.set_header('Cache-Control', 'no-store')
|