Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use embedded qt browser for jdaviz standalone #3188

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/standalone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,36 @@ jobs:
with:
python-version: "3.11"

- uses: ConorMacBride/install-package@v1
with:
# mirrored from glue-qt
# https://github.com/glue-viz/glue-qt/blob/main/.github/workflows/ci_workflows.yml
# using
# https://github.com/OpenAstronomy/github-actions-workflows/blob/5edb24fa432c75c0ca723ddea8ea14b72582919d/.github/workflows/tox.yml#L175C15-L175C49
# Linux PyQt 5.15 and 6.x installations require apt-getting xcb and EGL deps
# and headless X11 display;
apt: '^libxcb.*-dev libxkbcommon-x11-dev libegl1-mesa libopenblas-dev libhdf5-dev'

- name: Setup headless display
uses: pyvista/setup-headless-display-action@v2

- name: Install jdaviz
run: pip install .[test]
run: pip install .[test,qt]

- name: Install pyinstaller
run: pip install "pyinstaller<6"
# see https://github.com/erocarrera/pefile/issues/420 for performance issues on
# windows for pefile == 2024.8.26
# also see https://github.com/widgetti/solara/pull/724
# or https://solara.dev/documentation/advanced/howto/standalone (currently unpublished)
run: pip install "pyinstaller" "pefile<2024.8.26"

- name: Create standalone binary
env:
DEVELOPER_ID_APPLICATION: ${{ secrets.DEVELOPER_ID_APPLICATION }}
run: (cd standalone; pyinstaller ./jdaviz.spec)

- name: Run jdaviz cmd in background
run: ./standalone/dist/jdaviz/jdaviz-cli imviz&
run: ./standalone/dist/jdaviz/jdaviz-cli imviz --port 8765 &

- name: Install playwright
run: (pip install playwright; playwright install chromium)
Expand Down
34 changes: 23 additions & 11 deletions jdaviz/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def main(filepaths=None, layout='default', instrument=None, browser='default',
# easily accessed e.g. in the file load dialog.
os.environ['JDAVIZ_START_DIR'] = os.path.abspath('.')

from solara.__main__ import cli
from jdaviz import solara
solara.config = layout.capitalize()
solara.data_list = file_list
Expand All @@ -71,16 +70,29 @@ def main(filepaths=None, layout='default', instrument=None, browser='default',
solara.theme = theme
solara.jdaviz_verbosity = verbosity
solara.jdaviz_history_verbosity = history_verbosity
args = []
if hotreload:
args += ['--auto-restart']
run_solara(host=host, port=port, theme=theme, browser=browser, production=not hotreload)


def run_solara(host, port, theme, browser, production: bool = True):
os.environ["SOLARA_APP"] = "jdaviz.solara"
import solara.server.starlette
import solara.server.settings
solara.server.settings.theme.variant = theme
solara.server.settings.theme.loader = "plain"
solara.server.settings.main.mode = "production" if production else "development"

server = solara.server.starlette.ServerStarlette(host="localhost", port=port)
print(f"Starting server on {server.base_url}")
server.serve_threaded()
server.wait_until_serving()
if browser == "qt":
from . import qt
qt.run_qt(server.base_url)
else:
args += ['--production']
cli(['run', 'jdaviz.solara',
'--theme-loader', 'plain',
'--theme-variant', theme,
'--host', host,
'--port', port] + args)
import webbrowser
controller = webbrowser.get(None if browser == 'default' else browser)
controller.open(server.base_url)
server.join()
maartenbreddels marked this conversation as resolved.
Show resolved Hide resolved


def _main(config=None):
Expand All @@ -100,7 +112,7 @@ def _main(config=None):
parser.add_argument('--instrument', type=str, default='nirspec',
help='Manually specifies which instrument parser to use, for Mosviz')
parser.add_argument('--browser', type=str, default='default',
help='Browser to use for application.')
help='Browser to use for application (use qt for embedded Qt browser).')
parser.add_argument('--theme', choices=['light', 'dark'], default='light',
help='Theme to use for application.')
parser.add_argument('--verbosity', choices=_verbosity_levels, default='info',
Expand Down
126 changes: 126 additions & 0 deletions jdaviz/qt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# this module is based on solara/server/qt.py
import sys
from typing import List
import webbrowser
try:
from qtpy.QtWidgets import QApplication
from qtpy.QtWebEngineWidgets import QWebEngineView
from qtpy.QtWebChannel import QWebChannel
from qtpy import QtCore, QtGui
except ModuleNotFoundError as e:
raise ModuleNotFoundError("""Qt browser requires Qt dependencies, run:
$ pip install jdaviz[qt]
to install.""") from e
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()
width = geometry.width()
parent_size = self.size()
if width == 0:
width = parent_size.width()
height = geometry.height()
if height == 0:
height = parent_size.height()
print("new window", info.requestedUrl(), width, height)
webview.resize(width, height)
webview.setUrl(info.requestedUrl())
webview.show()
QWebEngineViewWithPopup.windows.append(webview)
return webview


def run_qt(url, app_name="Jdaviz"):
app = QApplication([])
web = QWebEngineViewWithPopup()
web.setUrl(QtCore.QUrl(url))
web.resize(1024, 1024)
web.show()

app.setApplicationDisplayName(app_name)
app.setApplicationName(app_name)
web.setWindowTitle(app_name)
app.setWindowIcon(QtGui.QIcon(str(HERE / "data/icons/imviz_icon.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_()
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ docs = [
roman = [
"roman_datamodels>=0.17.1",
]
qt = [
"qtpy",
"PySide6"
]

[build-system]
requires = [
Expand Down
5 changes: 5 additions & 0 deletions standalone/hooks/hook-matplotlib_inline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from PyInstaller.utils.hooks import collect_data_files, copy_metadata

datas = collect_data_files('matplotlib_inline')
# since matplotlib 3.9 entry_points.txt is needed
datas += copy_metadata('matplotlib_inline')
17 changes: 16 additions & 1 deletion standalone/jdaviz-cli-entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,24 @@
import matplotlib_inline
import matplotlib_inline.backend_inline

# We still see the above error on CI on jdaviz, and the PyInstaller
# output recommends the following:
import matplotlib
matplotlib.use("module://matplotlib_inline.backend_inline")
# since matplotlib 3.9 (see https://github.com/matplotlib/matplotlib/pull/27948),
# it seems that matplotlib_inline.backend_inline is an alias for inline
# so we make sure to communicate that to PyInstaller
matplotlib.use("inline")

import jdaviz.cli


if __name__ == "__main__":
# should change this to _main, but now it doesn't need arguments
jdaviz.cli.main(layout="")
args = sys.argv.copy()
# change the browser to qt if not specified
if "--browser" not in args:
args.append("--browser")
args.append("qt")
Comment on lines +26 to +28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since qt is an optional dependency (and we want to keep it that way), can this default to qt only if it is installed and otherwise use the system browser?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only for the standalone, which is built in CI with qt installed. Or do you mean if people build in locally?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if anyone installs jdaviz (from pip for example) but not using the standalone installer, they still have access to the CLI, but may not have the qt optional dependencies.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But they would not use this file right, maybe you are confusing it with cli.py?

sys.argv = args
jdaviz.cli._main()
2 changes: 1 addition & 1 deletion standalone/jdaviz.spec
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ exe = EXE(
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
Expand Down
Loading