Skip to content

Commit

Permalink
Merge branch 'on-air' into v1.3
Browse files Browse the repository at this point in the history
  • Loading branch information
falkoschindler committed Jul 12, 2023
2 parents 2147be6 + d73e934 commit 5744974
Show file tree
Hide file tree
Showing 10 changed files with 194 additions and 11 deletions.
115 changes: 115 additions & 0 deletions nicegui/air.py
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions nicegui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self, page: 'page', *, shared: bool = False) -> None:
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:
Expand Down
2 changes: 1 addition & 1 deletion nicegui/elements/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
2 changes: 1 addition & 1 deletion nicegui/elements/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def __init__(self,

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()
Expand Down
2 changes: 2 additions & 0 deletions nicegui/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .language import Language

if TYPE_CHECKING:
from .air import Air
from .client import Client
from .slot import Slot

Expand Down Expand Up @@ -42,6 +43,7 @@ class State(Enum):
language: Language
binding_refresh_interval: float
tailwind: bool
air: Optional['Air'] = None
socket_io_js_extra_headers: Dict = {}

_socket_id: Optional[str] = None
Expand Down
30 changes: 25 additions & 5 deletions nicegui/nicegui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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']


Expand Down
20 changes: 16 additions & 4 deletions nicegui/outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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 changes: 7 additions & 0 deletions nicegui/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@

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'
Expand Down Expand Up @@ -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,
Expand All @@ -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`)
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions nicegui/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down
19 changes: 19 additions & 0 deletions website/documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,4 +750,23 @@ def env_var_demo():
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')

0 comments on commit 5744974

Please sign in to comment.