|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
import asyncio
|
|
|
import time
|
|
|
import uuid
|
|
|
+from contextlib import contextmanager
|
|
|
from pathlib import Path
|
|
|
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Union
|
|
|
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Iterator, List, Optional, Union
|
|
|
|
|
|
from fastapi import Request
|
|
|
from fastapi.responses import Response
|
|
@@ -12,11 +13,13 @@ from fastapi.templating import Jinja2Templates
|
|
|
|
|
|
from nicegui import json
|
|
|
|
|
|
-from . import binding, globals, outbox # pylint: disable=redefined-builtin
|
|
|
+from . import background_tasks, binding, core, outbox
|
|
|
from .awaitable_response import AwaitableResponse
|
|
|
from .dependencies import generate_resources
|
|
|
from .element import Element
|
|
|
from .favicon import get_favicon_url
|
|
|
+from .helpers import safe_invoke
|
|
|
+from .logging import log
|
|
|
from .version import __version__
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
@@ -26,11 +29,19 @@ templates = Jinja2Templates(Path(__file__).parent / 'templates')
|
|
|
|
|
|
|
|
|
class Client:
|
|
|
+ page_routes: Dict[Callable[..., Any], str] = {}
|
|
|
+ """Maps page builders to their routes."""
|
|
|
+
|
|
|
+ instances: Dict[str, Client] = {}
|
|
|
+ """Maps client IDs to clients."""
|
|
|
+
|
|
|
+ auto_index_client: Client
|
|
|
+ """The client that is used to render the auto-index page."""
|
|
|
|
|
|
def __init__(self, page: page, *, shared: bool = False) -> None:
|
|
|
self.id = str(uuid.uuid4())
|
|
|
self.created = time.time()
|
|
|
- globals.clients[self.id] = self
|
|
|
+ self.instances[self.id] = self
|
|
|
|
|
|
self.elements: Dict[int, Element] = {}
|
|
|
self.next_element_id: int = 0
|
|
@@ -39,7 +50,7 @@ class Client:
|
|
|
self.environ: Optional[Dict[str, Any]] = None
|
|
|
self.shared = shared
|
|
|
self.on_air = False
|
|
|
- self.disconnect_task: Optional[asyncio.Task] = None
|
|
|
+ self._disconnect_task: Optional[asyncio.Task] = None
|
|
|
|
|
|
with Element('q-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
|
|
|
with Element('q-page-container') as self.page_container:
|
|
@@ -56,6 +67,13 @@ class Client:
|
|
|
self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
|
|
|
self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
|
|
|
|
|
|
+ self._temporary_socket_id: Optional[str] = None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def is_auto_index_client(self) -> bool:
|
|
|
+ """Return True if this client is the auto-index client."""
|
|
|
+ return self is self.auto_index_client
|
|
|
+
|
|
|
@property
|
|
|
def ip(self) -> Optional[str]:
|
|
|
"""Return the IP address of the client, or None if the client is not connected."""
|
|
@@ -79,7 +97,7 @@ class Client:
|
|
|
elements = json.dumps({
|
|
|
id: element._to_dict() for id, element in self.elements.items() # pylint: disable=protected-access
|
|
|
})
|
|
|
- socket_io_js_query_params = {**globals.socket_io_js_query_params, 'client_id': self.id}
|
|
|
+ socket_io_js_query_params = {**core.app.config.socket_io_js_query_params, 'client_id': self.id}
|
|
|
vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
|
|
|
return templates.TemplateResponse('index.html', {
|
|
|
'request': request,
|
|
@@ -93,18 +111,18 @@ class Client:
|
|
|
'vue_scripts': '\n'.join(vue_scripts),
|
|
|
'imports': json.dumps(imports),
|
|
|
'js_imports': '\n'.join(js_imports),
|
|
|
- 'quasar_config': json.dumps(globals.quasar_config),
|
|
|
+ 'quasar_config': json.dumps(core.app.config.quasar_config),
|
|
|
'title': self.page.resolve_title(),
|
|
|
'viewport': self.page.resolve_viewport(),
|
|
|
'favicon_url': get_favicon_url(self.page, prefix),
|
|
|
'dark': str(self.page.resolve_dark()),
|
|
|
'language': self.page.resolve_language(),
|
|
|
'prefix': prefix,
|
|
|
- 'tailwind': globals.tailwind,
|
|
|
- 'prod_js': globals.prod_js,
|
|
|
+ 'tailwind': core.app._run_config.tailwind, # pylint: disable=protected-access
|
|
|
+ 'prod_js': core.app._run_config.prod_js, # pylint: disable=protected-access
|
|
|
'socket_io_js_query_params': socket_io_js_query_params,
|
|
|
- 'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
|
|
|
- 'socket_io_js_transports': globals.socket_io_js_transports,
|
|
|
+ 'socket_io_js_extra_headers': core.app.config.socket_io_js_extra_headers,
|
|
|
+ 'socket_io_js_transports': core.app.config.socket_io_js_transports,
|
|
|
}, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
|
|
|
|
|
|
async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
|
|
@@ -122,7 +140,7 @@ class Client:
|
|
|
if not self.has_socket_connection:
|
|
|
await self.connected()
|
|
|
self.is_waiting_for_disconnect = True
|
|
|
- while self.id in globals.clients:
|
|
|
+ while self.id in self.instances:
|
|
|
await asyncio.sleep(check_interval)
|
|
|
self.is_waiting_for_disconnect = False
|
|
|
|
|
@@ -135,16 +153,16 @@ class Client:
|
|
|
You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
|
|
|
"""
|
|
|
if respond is True:
|
|
|
- globals.log.warning('The "respond" argument of run_javascript() has been removed. '
|
|
|
- 'Now the method always returns an AwaitableResponse that can be awaited. '
|
|
|
- 'Please remove the "respond=True" argument.')
|
|
|
+ log.warning('The "respond" argument of run_javascript() has been removed. '
|
|
|
+ 'Now the method always returns an AwaitableResponse that can be awaited. '
|
|
|
+ 'Please remove the "respond=True" argument.')
|
|
|
if respond is False:
|
|
|
raise ValueError('The "respond" argument of run_javascript() has been removed. '
|
|
|
'Now the method always returns an AwaitableResponse that can be awaited. '
|
|
|
'Please remove the "respond=False" argument and call the method without awaiting.')
|
|
|
|
|
|
request_id = str(uuid.uuid4())
|
|
|
- target_id = globals._socket_id or self.id # pylint: disable=protected-access
|
|
|
+ target_id = self._temporary_socket_id or self.id
|
|
|
|
|
|
def send_and_forget():
|
|
|
outbox.enqueue_message('run_javascript', {'code': code}, target_id)
|
|
@@ -162,7 +180,7 @@ class Client:
|
|
|
|
|
|
def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
|
|
|
"""Open a new page in the client."""
|
|
|
- path = target if isinstance(target, str) else globals.page_routes[target]
|
|
|
+ path = target if isinstance(target, str) else self.page_routes[target]
|
|
|
outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
|
|
|
|
|
|
def download(self, url: str, filename: Optional[str] = None) -> None:
|
|
@@ -177,6 +195,46 @@ class Client:
|
|
|
"""Register a callback to be called when the client disconnects."""
|
|
|
self.disconnect_handlers.append(handler)
|
|
|
|
|
|
+ def handle_handshake(self) -> None:
|
|
|
+ """Cancel pending disconnect task and invoke connect handlers."""
|
|
|
+ if self._disconnect_task:
|
|
|
+ self._disconnect_task.cancel()
|
|
|
+ self._disconnect_task = None
|
|
|
+ for t in self.connect_handlers:
|
|
|
+ safe_invoke(t, self)
|
|
|
+ for t in core.app._connect_handlers: # pylint: disable=protected-access
|
|
|
+ safe_invoke(t, self)
|
|
|
+
|
|
|
+ def handle_disconnect(self) -> None:
|
|
|
+ """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't."""
|
|
|
+ async def handle_disconnect() -> None:
|
|
|
+ if self.page.reconnect_timeout is not None:
|
|
|
+ delay = self.page.reconnect_timeout
|
|
|
+ else:
|
|
|
+ delay = core.app._run_config.reconnect_timeout # pylint: disable=protected-access
|
|
|
+ await asyncio.sleep(delay)
|
|
|
+ if not self.shared:
|
|
|
+ self.delete()
|
|
|
+ for t in self.disconnect_handlers:
|
|
|
+ safe_invoke(t, self)
|
|
|
+ for t in core.app._disconnect_handlers: # pylint: disable=protected-access
|
|
|
+ safe_invoke(t, self)
|
|
|
+ self._disconnect_task = background_tasks.create(handle_disconnect())
|
|
|
+
|
|
|
+ def handle_event(self, msg: Dict) -> None:
|
|
|
+ """Forward an event to the corresponding element."""
|
|
|
+ with self:
|
|
|
+ sender = self.elements.get(msg['id'])
|
|
|
+ if sender:
|
|
|
+ msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
|
|
|
+ if len(msg['args']) == 1:
|
|
|
+ msg['args'] = msg['args'][0]
|
|
|
+ sender._handle_event(msg) # pylint: disable=protected-access
|
|
|
+
|
|
|
+ def handle_javascript_response(self, msg: Dict) -> None:
|
|
|
+ """Store the result of a JavaScript command."""
|
|
|
+ self.waiting_javascript_commands[msg['request_id']] = msg['result']
|
|
|
+
|
|
|
def remove_elements(self, elements: Iterable[Element]) -> None:
|
|
|
"""Remove the given elements from the client."""
|
|
|
binding.remove(elements, Element)
|
|
@@ -191,3 +249,39 @@ class Client:
|
|
|
def remove_all_elements(self) -> None:
|
|
|
"""Remove all elements from the client."""
|
|
|
self.remove_elements(self.elements.values())
|
|
|
+
|
|
|
+ def delete(self) -> None:
|
|
|
+ """Delete a client and all its elements.
|
|
|
+
|
|
|
+ If the global clients dictionary does not contain the client, its elements are still removed and a KeyError is raised.
|
|
|
+ Normally this should never happen, but has been observed (see #1826).
|
|
|
+ """
|
|
|
+ self.remove_all_elements()
|
|
|
+ del Client.instances[self.id]
|
|
|
+
|
|
|
+ @contextmanager
|
|
|
+ def individual_target(self, socket_id: str) -> Iterator[None]:
|
|
|
+ """Use individual socket ID while in this context.
|
|
|
+
|
|
|
+ This context is useful for limiting messages from the shared auto-index page to a single client.
|
|
|
+ """
|
|
|
+ self._temporary_socket_id = socket_id
|
|
|
+ yield
|
|
|
+ self._temporary_socket_id = None
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ async def prune_instances(cls) -> None:
|
|
|
+ """Prune stale clients in an endless loop."""
|
|
|
+ while True:
|
|
|
+ try:
|
|
|
+ stale_clients = [
|
|
|
+ client
|
|
|
+ for client in cls.instances.values()
|
|
|
+ if not client.shared and not client.has_socket_connection and client.created < time.time() - 60.0
|
|
|
+ ]
|
|
|
+ for client in stale_clients:
|
|
|
+ client.delete()
|
|
|
+ except Exception:
|
|
|
+ # NOTE: make sure the loop doesn't crash
|
|
|
+ log.exception('Error while pruning clients')
|
|
|
+ await asyncio.sleep(10)
|