Skip to content

Commit

Permalink
feat: experimental support for running as (qt) app (#835)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
maartenbreddels authored Oct 25, 2024
1 parent 5aadae5 commit 0c8e08e
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 10 deletions.
33 changes: 23 additions & 10 deletions solara/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()


Expand Down
113 changes: 113 additions & 0 deletions solara/server/qt.py
Original file line number Diff line number Diff line change
@@ -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_()
Original file line number Diff line number Diff line change
Expand Up @@ -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
```


<img src="https://dxhl76zpt6fap.cloudfront.net/public/solara-quickstart-app.webp" alt="Markdown Monster icon"/>

0 comments on commit 0c8e08e

Please sign in to comment.