Browse Source

Merge branch 'on-air' into v1.3

Falko Schindler 1 year ago
parent
commit
574497459d

+ 115 - 0
nicegui/air.py

@@ -0,0 +1,115 @@
+import gzip
+import logging
+from typing import Any, Dict
+
+import httpx
+from socketio import AsyncClient
+
+from . import globals
+from .nicegui import handle_disconnect, handle_event, handle_handshake, handle_javascript_response
+
+RELAY_HOST = 'http://localhost'
+
+
+class Air:
+
+    def __init__(self, token: str) -> None:
+        self.token = token
+        self.relay = AsyncClient()
+        self.client = httpx.AsyncClient(app=globals.app)
+
+        @self.relay.on('http')
+        async def on_http(data: Dict[str, Any]) -> Dict[str, Any]:
+            headers: Dict[str, Any] = data['headers']
+            headers.update({'Accept-Encoding': 'identity', 'X-Forwarded-Prefix': data['prefix']})
+            url = 'http://test' + data['path']
+            request = self.client.build_request(
+                data['method'],
+                url,
+                params=data['params'],
+                headers=headers,
+                content=data['body'],
+            )
+            response = await self.client.send(request)
+            content = response.content.replace(
+                b'const extraHeaders = {};',
+                (f'const extraHeaders = {{ "fly-force-instance-id" : "{data["instance-id"]}" }};').encode(),
+            )
+            response_headers = dict(response.headers)
+            response_headers['content-encoding'] = 'gzip'
+            compressed = gzip.compress(content)
+            response_headers['content-length'] = str(len(compressed))
+            return {
+                'status_code': response.status_code,
+                'headers': response_headers,
+                'content': compressed,
+            }
+
+        @self.relay.on('ready')
+        def on_ready(data: Dict[str, Any]) -> None:
+            print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
+
+        @self.relay.on('error')
+        def on_error(data: Dict[str, Any]) -> None:
+            print('Error:', data['message'], flush=True)
+
+        @self.relay.on('handshake')
+        def on_handshake(data: Dict[str, Any]) -> bool:
+            client_id = data['client_id']
+            if client_id not in globals.clients:
+                return False
+            client = globals.clients[client_id]
+            client.environ = data['environ']
+            client.on_air = True
+            handle_handshake(client)
+            return True
+
+        @self.relay.on('client_disconnect')
+        def on_disconnect(data: Dict[str, Any]) -> None:
+            client_id = data['client_id']
+            if client_id not in globals.clients:
+                return
+            client = globals.clients[client_id]
+            handle_disconnect(client)
+
+        @self.relay.on('event')
+        def on_event(data: Dict[str, Any]) -> None:
+            client_id = data['client_id']
+            if client_id not in globals.clients:
+                return
+            client = globals.clients[client_id]
+            if isinstance(data['msg']['args'], list) and 'socket_id' in data['msg']['args']:
+                data['msg']['args']['socket_id'] = client_id  # HACK: translate socket_id of ui.scene's init event
+            handle_event(client, data['msg'])
+
+        @self.relay.on('javascript_response')
+        def on_javascript_response(data: Dict[str, Any]) -> None:
+            client_id = data['client_id']
+            if client_id not in globals.clients:
+                return
+            client = globals.clients[client_id]
+            handle_javascript_response(client, data['msg'])
+
+        @self.relay.on('out_of_time')
+        async def on_move() -> None:
+            print('Sorry, you have reached the time limit of this on-air preview.', flush=True)
+            await self.connect()
+
+    async def connect(self) -> None:
+        try:
+            if self.relay.connected:
+                await self.relay.disconnect()
+            await self.relay.connect(
+                f'{RELAY_HOST}?device_token={self.token}',
+                socketio_path='/on_air/socket.io',
+                transports=['websocket', 'polling'],
+            )
+        except:
+            logging.exception('Could not connect to NiceGUI on air server.')
+            print('Could not connect to NiceGUI on air server.', flush=True)
+
+    async def disconnect(self) -> None:
+        await self.relay.disconnect()
+
+    async def emit(self, message_type: str, data: Dict[str, Any], room: str) -> None:
+        await self.relay.emit('forward', {'event': message_type, 'data': data, 'room': room})

+ 1 - 0
nicegui/client.py

@@ -34,6 +34,7 @@ class Client:
         self.is_waiting_for_disconnect: bool = False
         self.environ: Optional[Dict[str, Any]] = None
         self.shared = shared
+        self.on_air = False
 
         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:

+ 1 - 1
nicegui/elements/scene.js

@@ -154,7 +154,7 @@ export default {
 
     const connectInterval = setInterval(async () => {
       if (window.socket.id === undefined) return;
-      this.$emit("init", window.socket.id);
+      this.$emit("init", { socket_id: window.socket.id });
       clearInterval(connectInterval);
     }, 100);
   },

+ 1 - 1
nicegui/elements/scene.py

@@ -84,7 +84,7 @@ class Scene(Element,
 
     def handle_init(self, e: GenericEventArguments) -> None:
         self.is_initialized = True
-        with globals.socket_id(e.args):
+        with globals.socket_id(e.args['socket_id']):
             self.move_camera(duration=0)
             for object in self.objects.values():
                 object.send()

+ 2 - 0
nicegui/globals.py

@@ -14,6 +14,7 @@ from .app import App
 from .language import Language
 
 if TYPE_CHECKING:
+    from .air import Air
     from .client import Client
     from .slot import Slot
 
@@ -42,6 +43,7 @@ dark: Optional[bool]
 language: Language
 binding_refresh_interval: float
 tailwind: bool
+air: Optional['Air'] = None
 socket_io_js_extra_headers: Dict = {}
 
 _socket_id: Optional[str] = None

+ 25 - 5
nicegui/nicegui.py

@@ -88,6 +88,8 @@ def handle_startup(with_welcome_message: bool = True) -> None:
     globals.state = globals.State.STARTED
     if with_welcome_message:
         print_welcome_message()
+    if globals.air:
+        background_tasks.create(globals.air.connect())
 
 
 def print_welcome_message():
@@ -110,6 +112,8 @@ async def handle_shutdown() -> None:
         for t in globals.shutdown_handlers:
             safe_invoke(t)
     globals.state = globals.State.STOPPED
+    if globals.air:
+        await globals.air.disconnect()
 
 
 @app.exception_handler(404)
@@ -129,24 +133,32 @@ async def exception_handler_500(request: Request, exception: Exception) -> Respo
 
 
 @sio.on('handshake')
-def handle_handshake(sid: str) -> bool:
+def on_handshake(sid: str) -> bool:
     client = get_client(sid)
     if not client:
         return False
     client.environ = sio.get_environ(sid)
     sio.enter_room(sid, client.id)
+    handle_handshake(client)
+    return True
+
+
+def handle_handshake(client: Client) -> None:
     for t in client.connect_handlers:
         safe_invoke(t, client)
     for t in globals.connect_handlers:
         safe_invoke(t, client)
-    return True
 
 
 @sio.on('disconnect')
-def handle_disconnect(sid: str) -> None:
+def on_disconnect(sid: str) -> None:
     client = get_client(sid)
     if not client:
         return
+    handle_disconnect(client)
+
+
+def handle_disconnect(client: Client) -> None:
     if not client.shared:
         delete_client(client.id)
     for t in client.disconnect_handlers:
@@ -156,10 +168,14 @@ def handle_disconnect(sid: str) -> None:
 
 
 @sio.on('event')
-def handle_event(sid: str, msg: Dict) -> None:
+def on_event(sid: str, msg: Dict) -> None:
     client = get_client(sid)
     if not client or not client.has_socket_connection:
         return
+    handle_event(client, msg)
+
+
+def handle_event(client: Client, msg: Dict) -> None:
     with client:
         sender = client.elements.get(msg['id'])
         if sender:
@@ -170,10 +186,14 @@ def handle_event(sid: str, msg: Dict) -> None:
 
 
 @sio.on('javascript_response')
-def handle_javascript_response(sid: str, msg: Dict) -> None:
+def on_javascript_response(sid: str, msg: Dict) -> None:
     client = get_client(sid)
     if not client:
         return
+    handle_javascript_response(client, msg)
+
+
+def handle_javascript_response(client: Client, msg: Dict) -> None:
     client.waiting_javascript_commands[msg['request_id']] = msg['result']
 
 

+ 16 - 4
nicegui/outbox.py

@@ -20,8 +20,8 @@ def enqueue_update(element: 'Element') -> None:
     update_queue[element.client.id][element.id] = element
 
 
-def enqueue_message(message_type: 'MessageType', data: Any, client_id: 'ClientId') -> None:
-    message_queue.append((client_id, message_type, data))
+def enqueue_message(message_type: 'MessageType', data: Any, target_id: 'ClientId') -> None:
+    message_queue.append((target_id, message_type, data))
 
 
 async def loop() -> None:
@@ -34,9 +34,14 @@ async def loop() -> None:
             for client_id, elements in update_queue.items():
                 data = {element_id: element._to_dict() for element_id, element in elements.items()}
                 coros.append(globals.sio.emit('update', data, room=client_id))
+                if is_target_on_air(client_id):
+                    coros.append(globals.air.emit('update', data, room=client_id))
+
             update_queue.clear()
-            for client_id, message_type, data in message_queue:
-                coros.append(globals.sio.emit(message_type, data, room=client_id))
+            for target_id, message_type, data in message_queue:
+                coros.append(globals.sio.emit(message_type, data, room=target_id))
+                if is_target_on_air(target_id):
+                    coros.append(globals.air.emit(message_type, data, room=target_id))
             message_queue.clear()
             for coro in coros:
                 try:
@@ -46,3 +51,10 @@ async def loop() -> None:
         except Exception as e:
             globals.handle_exception(e)
             await asyncio.sleep(0.1)
+
+
+def is_target_on_air(target_id: str) -> bool:
+    if target_id in globals.clients:
+        return globals.clients[target_id].on_air
+    else:
+        return target_id in globals.sio.manager.rooms

+ 7 - 0
nicegui/run.py

@@ -8,12 +8,14 @@ from typing import Any, List, Optional, Tuple, Union
 
 import __main__
 import uvicorn
+from typing_extensions import Literal
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
 from . import globals, helpers
 from . import native as native_module
 from . import native_mode
+from .air import Air
 from .language import Language
 
 APP_IMPORT_STRING = 'nicegui:app'
@@ -42,6 +44,7 @@ def run(*,
         language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
         show: bool = True,
+        on_air: Optional[Union[str, Literal[True]]] = None,
         native: bool = False,
         window_size: Optional[Tuple[int, int]] = None,
         fullscreen: bool = False,
@@ -67,6 +70,7 @@ def run(*,
     :param language: language for Quasar elements (default: `'en-US'`)
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param show: automatically open the UI in a browser tab (default: `True`)
+    :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation#nicegui_on_air>`_ if set to `True` (default: disabled)
     :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
     :param window_size: open the UI in a native window with the provided size (e.g. `(1024, 786)`, default: `None`, also activates `native`)
     :param fullscreen: open the UI in a fullscreen window (default: `False`, also activates `native`)
@@ -89,6 +93,9 @@ def run(*,
     globals.binding_refresh_interval = binding_refresh_interval
     globals.tailwind = tailwind
 
+    if on_air:
+        globals.air = Air('' if on_air is True else on_air)
+
     if multiprocessing.current_process().name != 'MainProcess':
         return
 

+ 7 - 0
nicegui/templates/index.html

@@ -232,6 +232,13 @@
             connect_error: (err) => {
               if (err.message == 'timeout') window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
             },
+            try_reconnect: () => {
+              const checkAndReload = async () => {
+                await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
+                window.location.reload();
+              };
+              setInterval(checkAndReload, 500);
+            },
             disconnect: () => {
               document.getElementById('popup').style.opacity = 1;
             },

+ 19 - 0
website/documentation.py

@@ -750,4 +750,23 @@ def create_full() -> None:
         See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
     ''')
 
+    subheading('NiceGUI On Air')
+
+    ui.markdown('''
+        By using `ui.run(on_air=True)` you can share your local app with others over the internet 🧞.
+
+        When accessing the on-air URL, all libraries (like Vue, Quasar, ...) are loaded from our CDN.
+        Thereby only the raw content and events need to be transmitted by your local app.
+        This makes it blazing fast even if your app only has a poor internet connection (e.g. a mobile robot in the field).
+
+        Currently "On Air" is available as a tech preview and generates a random URL that is valid for 1 hour.
+        We will gradually improve stability and extend the service with password protection, custom URLs and more.
+        Please let us know your feedback on [GitHub](https://github.com/zauberzeug/nicegui/discussions),
+        [Reddit](https://www.reddit.com/r/nicegui/), or [Discord](https://discord.gg/3XkZVYJ).
+
+        **Data Privacy:**
+        We take your privacy very serious.
+        NiceGUI On Air does not log or store any content of the relayed data.
+    ''').classes('bold-links arrow-links')
+
     ui.element('div').classes('h-32')