From 0c8e08e10dcdafaff7972d9cb8c0ac8a74e0d75a Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 25 Oct 2024 12:26:23 +0200 Subject: [PATCH] feat: experimental support for running as (qt) app (#835) Using QtWebEngine to run Solara as a standalone app. This is experimental, and possibly useful for developer that do not like that solara opens in a new browser tab each time. It is also useful for testing before packaging as a standaline binary, see: #724 --- solara/__main__.py | 33 +++-- solara/server/qt.py | 113 ++++++++++++++++++ .../getting_started/content/00-quickstart.md | 18 +++ 3 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 solara/server/qt.py diff --git a/solara/__main__.py b/solara/__main__.py index 06d898e00..b143a855f 100644 --- a/solara/__main__.py +++ b/solara/__main__.py @@ -261,6 +261,12 @@ def cli(): default=True, help="Check installed version again pypi version.", ) +@click.option( + "--qt", + is_flag=True, + default=False, + help="Instead of opening a browser, open a Qt window. Will also stop the server when the window is closed. (experimental)", +) def run( app, host, @@ -290,6 +296,7 @@ def run( ssg: bool, search: bool, check_version: bool = True, + qt=False, ): """Run a Solara app.""" if dev is not None: @@ -365,9 +372,16 @@ def open_browser(): while not failed and (server is None or not server.started): time.sleep(0.1) if not failed: - webbrowser.open(url) + if qt: + from .server.qt import run_qt - if open: + run_qt(url) + else: + webbrowser.open(url) + + # with qt, we open the browser in the main thread (qt wants that) + # otherwise, we open the browser in a separate thread + if open and not qt: threading.Thread(target=open_browser, daemon=True).start() rich.print(f"Solara server is starting at {url}") @@ -397,7 +411,7 @@ def open_browser(): settings.main.timing = timing items = ( "theme_variant_user_selectable dark theme_variant theme_loader use_pdb server open_browser open url failed dev tracer" - " timing ssg search check_version production".split() + " timing ssg search check_version production qt".split() ) for item in items: del kwargs[item] @@ -451,14 +465,13 @@ def ssg_run(): build_index("") - start_server() - # TODO: if we want to use webview, it should be sth like this - # server_thread = threading.Thread(target=start_server) - # server_thread.start() - # if open: - # # open_webview() - # open_browser() + if qt: + server_thread = threading.Thread(target=start_server, daemon=True) + server_thread.start() + open_browser() + else: + start_server() # server_thread.join() diff --git a/solara/server/qt.py b/solara/server/qt.py new file mode 100644 index 000000000..67e9f566c --- /dev/null +++ b/solara/server/qt.py @@ -0,0 +1,113 @@ +import sys +from typing import List +import webbrowser +from qtpy.QtWidgets import QApplication +from qtpy.QtWebEngineWidgets import QWebEngineView +from qtpy.QtWebChannel import QWebChannel +from qtpy import QtCore, QtGui +import signal +from pathlib import Path + +HERE = Path(__file__).parent + + +# setUrlRequestInterceptor, navigationRequested and acceptNavigationRequest +# all trigger the websocket to disconnect, so we need to block cross origin +# requests on the frontend/browser side by intercepting clicks on links + +cross_origin_block_js = """ +var script = document.createElement('script'); +script.src = 'qrc:///qtwebchannel/qwebchannel.js'; +document.head.appendChild(script); +script.onload = function() { + new QWebChannel(qt.webChannelTransport, function(channel) { + let py_callback = channel.objects.py_callback; + + document.addEventListener('click', function(event) { + let target = event.target; + while (target && target.tagName !== 'A') { + target = target.parentNode; + } + + if (target && target.tagName === 'A') { + const linkOrigin = new URL(target.href).origin; + const currentOrigin = window.location.origin; + + if (linkOrigin !== currentOrigin) { + event.preventDefault(); + console.log("Blocked cross-origin navigation to:", target.href); + py_callback.open_link(target.href); // Call Python method + } + } + }, true); + }); +}; +""" + + +class PyCallback(QtCore.QObject): + @QtCore.Slot(str) + def open_link(self, url): + webbrowser.open(url) + + +class QWebEngineViewWithPopup(QWebEngineView): + # keep a strong reference to all windows + windows: List = [] + + def __init__(self): + super().__init__() + self.page().newWindowRequested.connect(self.handle_new_window_request) + + # Set up WebChannel and py_callback object + self.py_callback = PyCallback() + self.channel = QWebChannel() + self.channel.registerObject("py_callback", self.py_callback) + self.page().setWebChannel(self.channel) + + self.loadFinished.connect(self._inject_javascript) + + def _inject_javascript(self, ok): + self.page().runJavaScript(cross_origin_block_js) + + def handle_new_window_request(self, info): + webview = QWebEngineViewWithPopup() + geometry = info.requestedGeometry() + webview.resize(geometry.width(), geometry.height()) + webview.setUrl(info.requestedUrl()) + webview.show() + QWebEngineViewWithPopup.windows.append(webview) + return webview + + +def run_qt(url): + app = QApplication([]) + web = QWebEngineViewWithPopup() + web.setUrl(QtCore.QUrl(url)) + web.resize(1024, 1024) + web.show() + + app_name = "Solara" + app.setApplicationDisplayName(app_name) + app.setApplicationName(app_name) + web.setWindowTitle(app_name) + app.setWindowIcon(QtGui.QIcon(str(HERE.parent / "website/public/logo.svg"))) + if sys.platform.startswith("darwin"): + # Set app name, if PyObjC is installed + # Python 2 has PyObjC preinstalled + # Python 3: pip3 install pyobjc-framework-Cocoa + try: + from Foundation import NSBundle + + bundle = NSBundle.mainBundle() + if bundle: + app_info = bundle.localizedInfoDictionary() or bundle.infoDictionary() + if app_info is not None: + app_info["CFBundleName"] = app_name + app_info["CFBundleDisplayName"] = app_name + except ModuleNotFoundError: + pass + + # without this, ctrl-c does not work in the terminal + signal.signal(signal.SIGINT, signal.SIG_DFL) + app.exec_() diff --git a/solara/website/pages/documentation/getting_started/content/00-quickstart.md b/solara/website/pages/documentation/getting_started/content/00-quickstart.md index c1729e6a2..7816d61ec 100644 --- a/solara/website/pages/documentation/getting_started/content/00-quickstart.md +++ b/solara/website/pages/documentation/getting_started/content/00-quickstart.md @@ -87,3 +87,21 @@ In case you forgot how to start a notebook server: Or the more modern Jupyter lab: $ jupyter lab + + +## Run as app (experimental) + +You can also run the script as a standalone app. This requires the extra packages `qtpy` and `PySide6` (or `PyQt6`) to be installed. + +```bash +$ pip install pip install qtpy PySide6 +``` + +Run from the command line in the same directory where you put your file (`sol.py`): + +```bash +$ solara run sol.py --qt +``` + + +Markdown Monster icon