client.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import asyncio
  2. import time
  3. import uuid
  4. from pathlib import Path
  5. from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
  6. from fastapi import Request
  7. from fastapi.responses import Response
  8. from fastapi.templating import Jinja2Templates
  9. from nicegui import json
  10. from . import __version__, globals, outbox
  11. from .dependencies import generate_resources
  12. from .element import Element
  13. from .favicon import get_favicon_url
  14. if TYPE_CHECKING:
  15. from .page import page
  16. templates = Jinja2Templates(Path(__file__).parent / 'templates')
  17. class Client:
  18. def __init__(self, page: 'page', *, shared: bool = False) -> None:
  19. self.id = str(uuid.uuid4())
  20. self.created = time.time()
  21. globals.clients[self.id] = self
  22. self.elements: Dict[int, Element] = {}
  23. self.next_element_id: int = 0
  24. self.is_waiting_for_connection: bool = False
  25. self.is_waiting_for_disconnect: bool = False
  26. self.environ: Optional[Dict[str, Any]] = None
  27. self.shared = shared
  28. with Element('q-layout', _client=self).props('view="HHH LpR FFF"').classes('nicegui-layout') as self.layout:
  29. with Element('q-page-container'):
  30. with Element('q-page'):
  31. self.content = Element('div').classes('nicegui-content')
  32. self.waiting_javascript_commands: Dict[str, str] = {}
  33. self.head_html = ''
  34. self.body_html = ''
  35. self.page = page
  36. self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  37. self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  38. @property
  39. def ip(self) -> Optional[str]:
  40. """Return the IP address of the client, or None if the client is not connected."""
  41. return self.environ['asgi.scope']['client'][0] if self.environ else None
  42. @property
  43. def has_socket_connection(self) -> bool:
  44. """Return True if the client is connected, False otherwise."""
  45. return self.environ is not None
  46. def __enter__(self):
  47. self.content.__enter__()
  48. return self
  49. def __exit__(self, *_):
  50. self.content.__exit__()
  51. def build_response(self, request: Request, status_code: int = 200) -> Response:
  52. prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
  53. elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
  54. vue_html, vue_styles, vue_scripts, import_maps, js_imports = generate_resources(prefix, self.elements.values())
  55. return templates.TemplateResponse('index.html', {
  56. 'request': request,
  57. 'version': __version__,
  58. 'client_id': str(self.id),
  59. 'elements': elements,
  60. 'head_html': self.head_html,
  61. 'body_html': f'{vue_styles}\n{self.body_html}\n{vue_html}',
  62. 'vue_scripts': vue_scripts,
  63. 'import_maps': import_maps,
  64. 'js_imports': js_imports,
  65. 'title': self.page.resolve_title(),
  66. 'viewport': self.page.resolve_viewport(),
  67. 'favicon_url': get_favicon_url(self.page, prefix),
  68. 'dark': str(self.page.resolve_dark()),
  69. 'language': self.page.resolve_language(),
  70. 'prefix': prefix,
  71. 'tailwind': globals.tailwind,
  72. 'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
  73. }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
  74. async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
  75. """Block execution until the client is connected."""
  76. self.is_waiting_for_connection = True
  77. deadline = time.time() + timeout
  78. while not self.environ:
  79. if time.time() > deadline:
  80. raise TimeoutError(f'No connection after {timeout} seconds')
  81. await asyncio.sleep(check_interval)
  82. self.is_waiting_for_connection = False
  83. async def disconnected(self, check_interval: float = 0.1) -> None:
  84. """Block execution until the client disconnects."""
  85. self.is_waiting_for_disconnect = True
  86. while self.id in globals.clients:
  87. await asyncio.sleep(check_interval)
  88. self.is_waiting_for_disconnect = False
  89. async def run_javascript(self, code: str, *,
  90. respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
  91. """Execute JavaScript on the client.
  92. The client connection must be established before this method is called.
  93. You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
  94. If respond is True, the javascript code must return a string.
  95. """
  96. request_id = str(uuid.uuid4())
  97. command = {
  98. 'code': code,
  99. 'request_id': request_id if respond else None,
  100. }
  101. outbox.enqueue_message('run_javascript', command, self.id)
  102. if not respond:
  103. return None
  104. deadline = time.time() + timeout
  105. while request_id not in self.waiting_javascript_commands:
  106. if time.time() > deadline:
  107. raise TimeoutError('JavaScript did not respond in time')
  108. await asyncio.sleep(check_interval)
  109. return self.waiting_javascript_commands.pop(request_id)
  110. def open(self, target: Union[Callable[..., Any], str]) -> None:
  111. """Open a new page in the client."""
  112. path = target if isinstance(target, str) else globals.page_routes[target]
  113. outbox.enqueue_message('open', path, self.id)
  114. def download(self, url: str, filename: Optional[str] = None) -> None:
  115. """Download a file from the given URL."""
  116. outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
  117. def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
  118. """Register a callback to be called when the client connects."""
  119. self.connect_handlers.append(handler)
  120. def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
  121. """Register a callback to be called when the client disconnects."""
  122. self.disconnect_handlers.append(handler)