diff --git a/src/calibre/ebooks/oeb/polish/check/css.py b/src/calibre/ebooks/oeb/polish/check/css.py index 8ea369e859..ba9890a26d 100644 --- a/src/calibre/ebooks/oeb/polish/check/css.py +++ b/src/calibre/ebooks/oeb/polish/check/css.py @@ -16,7 +16,7 @@ from qt.webengine import ( from calibre import detect_ncpus as cpu_count, prints from calibre.ebooks.oeb.polish.check.base import ERROR, WARN, BaseError from calibre.gui2 import must_use_qt -from calibre.gui2.webengine import secure_webengine +from calibre.utils.webengine import secure_webengine class CSSParseError(BaseError): diff --git a/src/calibre/ebooks/pdf/html_writer.py b/src/calibre/ebooks/pdf/html_writer.py index d639bd72fb..07c5f8f58f 100644 --- a/src/calibre/ebooks/pdf/html_writer.py +++ b/src/calibre/ebooks/pdf/html_writer.py @@ -31,7 +31,7 @@ from calibre.ebooks.pdf.image_writer import ( ) from calibre.ebooks.pdf.render.serialize import PDFStream from calibre.gui2 import setup_unix_signals -from calibre.gui2.webengine import secure_webengine +from calibre.utils.webengine import secure_webengine from calibre.srv.render_book import check_for_maths from calibre.utils.fonts.sfnt.container import Sfnt, UnsupportedFont from calibre.utils.fonts.sfnt.errors import NoGlyphs diff --git a/src/calibre/ebooks/render_html.py b/src/calibre/ebooks/render_html.py index 2e063afe52..f22404ebab 100644 --- a/src/calibre/ebooks/render_html.py +++ b/src/calibre/ebooks/render_html.py @@ -12,7 +12,7 @@ from qt.webengine import QWebEnginePage, QWebEngineScript from calibre.ebooks.metadata.pdf import page_images from calibre.gui2 import must_use_qt -from calibre.gui2.webengine import secure_webengine +from calibre.utils.webengine import secure_webengine from calibre.utils.filenames import atomic_rename from calibre.utils.monotonic import monotonic diff --git a/src/calibre/gui2/toc/location.py b/src/calibre/gui2/toc/location.py index e61b32a9cd..5e7739b5a3 100644 --- a/src/calibre/gui2/toc/location.py +++ b/src/calibre/gui2/toc/location.py @@ -11,7 +11,7 @@ from qt.webengine import QWebEnginePage, QWebEngineScript, QWebEngineView from calibre.gui2 import error_dialog, gprefs, is_dark_theme, question_dialog from calibre.gui2.palette import dark_color, dark_link_color, dark_text_color -from calibre.gui2.webengine import secure_webengine +from calibre.utils.webengine import secure_webengine from calibre.utils.logging import default_log from calibre.utils.short_uuid import uuid4 diff --git a/src/calibre/gui2/tweak_book/preview.py b/src/calibre/gui2/tweak_book/preview.py index 3ea9cb3cf4..cfdf0113cc 100644 --- a/src/calibre/gui2/tweak_book/preview.py +++ b/src/calibre/gui2/tweak_book/preview.py @@ -33,10 +33,11 @@ from calibre.gui2.palette import dark_color, dark_link_color, dark_text_color from calibre.gui2.tweak_book import TOP, actions, current_container, editors, tprefs from calibre.gui2.tweak_book.file_list import OpenWithHandler from calibre.gui2.viewer.web_view import handle_mathjax_request, send_reply -from calibre.gui2.webengine import ( - Bridge, RestartingWebEngineView, create_script, from_js, insert_scripts, +from calibre.utils.webengine import ( + Bridge, create_script, from_js, insert_scripts, secure_webengine, to_js ) +from calibre.gui2.webengine import RestartingWebEngineView from calibre.gui2.widgets2 import HistoryLineEdit2 from calibre.utils.ipc.simple_worker import offload_worker from polyglot.builtins import iteritems diff --git a/src/calibre/gui2/tweak_book/reports.py b/src/calibre/gui2/tweak_book/reports.py index 8a13d4a6be..55b8e60cbb 100644 --- a/src/calibre/gui2/tweak_book/reports.py +++ b/src/calibre/gui2/tweak_book/reports.py @@ -28,7 +28,8 @@ from calibre.ebooks.oeb.polish.report import ( gather_data, CSSEntry, CSSFileMatch, MatchLocation, ClassEntry, ClassFileMatch, ClassElement, CSSRule, LinkLocation) from calibre.gui2 import error_dialog, question_dialog, choose_save_file, open_url -from calibre.gui2.webengine import secure_webengine, RestartingWebEngineView +from calibre.utils.webengine import secure_webengine +from calibre.gui2.webengine import RestartingWebEngineView from calibre.gui2.tweak_book import current_container, tprefs, dictionaries from calibre.gui2.tweak_book.widgets import Dialog from calibre.gui2.progress_indicator import ProgressIndicator diff --git a/src/calibre/gui2/viewer/lookup.py b/src/calibre/gui2/viewer/lookup.py index 7317400c04..c235659e14 100644 --- a/src/calibre/gui2/viewer/lookup.py +++ b/src/calibre/gui2/viewer/lookup.py @@ -18,7 +18,7 @@ from calibre import prints, random_user_agent from calibre.constants import cache_dir from calibre.gui2 import error_dialog from calibre.gui2.viewer.web_view import apply_font_settings, vprefs -from calibre.gui2.webengine import create_script, insert_scripts, secure_webengine +from calibre.utils.webengine import create_script, insert_scripts, secure_webengine from calibre.gui2.widgets2 import Dialog vprefs.defaults['lookup_locations'] = [ diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index 8cae528c00..104bf7b055 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -30,10 +30,11 @@ from calibre.gui2 import choose_images, error_dialog, safe_open_url, config from calibre.gui2.viewer import link_prefix_for_location_links, performance_monitor from calibre.gui2.viewer.config import viewer_config_dir, vprefs from calibre.gui2.viewer.tts import TTS -from calibre.gui2.webengine import ( - Bridge, RestartingWebEngineView, create_script, from_js, insert_scripts, +from calibre.utils.webengine import ( + Bridge, create_script, from_js, insert_scripts, secure_webengine, to_js ) +from calibre.gui2.webengine import RestartingWebEngineView from calibre.srv.code import get_translations_data from calibre.utils.localization import localize_user_manual_link from calibre.utils.serialize import json_loads diff --git a/src/calibre/gui2/webengine.py b/src/calibre/gui2/webengine.py index 438e0b0dd7..de228b55b6 100644 --- a/src/calibre/gui2/webengine.py +++ b/src/calibre/gui2/webengine.py @@ -1,140 +1,11 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2018, Kovid Goyal - -import json - -from qt.core import QObject, Qt, pyqtSignal -from qt.webengine import QWebEnginePage, QWebEngineScript, QWebEngineView, QWebEngineSettings +from qt.core import Qt, pyqtSignal +from qt.webengine import QWebEnginePage, QWebEngineView from calibre import prints from calibre.utils.monotonic import monotonic -from calibre.utils.rapydscript import special_title -from polyglot.builtins import iteritems - - -def secure_webengine(view_or_page_or_settings, for_viewer=False): - s = view_or_page_or_settings.settings() if hasattr( - view_or_page_or_settings, 'settings') else view_or_page_or_settings - a = s.setAttribute - a(QWebEngineSettings.WebAttribute.PluginsEnabled, False) - if not for_viewer: - a(QWebEngineSettings.WebAttribute.JavascriptEnabled, False) - s.setUnknownUrlSchemePolicy(QWebEngineSettings.UnknownUrlSchemePolicy.DisallowUnknownUrlSchemes) - if hasattr(view_or_page_or_settings, 'setAudioMuted'): - view_or_page_or_settings.setAudioMuted(True) - a(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, False) - a(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, False) - # ensure javascript cannot read from local files - a(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False) - a(QWebEngineSettings.WebAttribute.AllowWindowActivationFromJavaScript, False) - return s - - -def insert_scripts(profile, *scripts): - sc = profile.scripts() - for script in scripts: - for existing in sc.find(script.name()): - sc.remove(existing) - for script in scripts: - sc.insert(script) - - -def create_script( - name, src, world=QWebEngineScript.ScriptWorldId.ApplicationWorld, - injection_point=QWebEngineScript.InjectionPoint.DocumentReady, - on_subframes=True -): - script = QWebEngineScript() - if isinstance(src, bytes): - src = src.decode('utf-8') - script.setSourceCode(src) - script.setName(name) - script.setWorldId(world) - script.setInjectionPoint(injection_point) - script.setRunsOnSubFrames(on_subframes) - return script - - -from_js = pyqtSignal - - -class to_js(str): - - def __call__(self, *a): - prints(f'WARNING: Calling {self.name}() before the javascript bridge is ready') - emit = __call__ - - -class to_js_bound(QObject): - - def __init__(self, bridge, name): - QObject.__init__(self, bridge) - self.name = name - - def __call__(self, *args): - self.parent().page.runJavaScript('if (window.python_comm) python_comm._from_python({}, {})'.format( - json.dumps(self.name), json.dumps(args)), QWebEngineScript.ScriptWorldId.ApplicationWorld) - emit = __call__ - - -class Bridge(QObject): - - bridge_ready = pyqtSignal() - - def __init__(self, page): - QObject.__init__(self, page) - self._signals = json.dumps(tuple({k for k, v in iteritems(self.__class__.__dict__) if isinstance(v, pyqtSignal)})) - self._signals_registered = False - page.titleChanged.connect(self._title_changed) - for k, v in iteritems(self.__class__.__dict__): - if isinstance(v, to_js): - v.name = k - - @property - def page(self): - return self.parent() - - @property - def ready(self): - return self._signals_registered - - def _title_changed(self, title): - if title.startswith(special_title): - self._poll_for_messages() - - def _register_signals(self): - self._signals_registered = True - for k, v in iteritems(self.__class__.__dict__): - if isinstance(v, to_js): - setattr(self, k, to_js_bound(self, k)) - self.page.runJavaScript('python_comm._register_signals(' + self._signals + ')', QWebEngineScript.ScriptWorldId.ApplicationWorld) - self.bridge_ready.emit() - - def _poll_for_messages(self): - self.page.runJavaScript('python_comm._poll()', QWebEngineScript.ScriptWorldId.ApplicationWorld, self._dispatch_messages) - - def _dispatch_messages(self, messages): - try: - for msg in messages: - if isinstance(msg, dict): - mt = msg.get('type') - if mt == 'signal': - signal = getattr(self, msg['name'], None) - if signal is None: - prints('WARNING: No js-to-python signal named: ' + msg['name']) - else: - args = msg['args'] - if args: - signal.emit(*args) - else: - signal.emit() - elif mt == 'qt-ready': - self._register_signals() - except Exception: - if messages: - import traceback - traceback.print_exc() class RestartingWebEngineView(QWebEngineView): @@ -161,29 +32,3 @@ class RestartingWebEngineView(QWebEngineView): self._last_reload_at = monotonic() self.render_process_restarted.emit() prints('Restarting Qt WebEngine') - - -if __name__ == '__main__': - from calibre.gui2 import Application - from calibre.gui2.tweak_book.preview import WebPage - from qt.core import QMainWindow - app = Application([]) - view = QWebEngineView() - page = WebPage(view) - view.setPage(page) - w = QMainWindow() - w.setCentralWidget(view) - - class Test(Bridge): - s1 = from_js(object) - j1 = to_js() - t = Test(view.page()) - t.s1.connect(print) - w.show() - view.setHtml(''' -

hello

- ''') - app.exec() - del t - del page - del app diff --git a/src/calibre/utils/rapydscript.py b/src/calibre/utils/rapydscript.py index f55dbabf1c..7b11c52c42 100644 --- a/src/calibre/utils/rapydscript.py +++ b/src/calibre/utils/rapydscript.py @@ -59,7 +59,7 @@ def compiler(): from calibre import walk from calibre.gui2 import must_use_qt - from calibre.gui2.webengine import secure_webengine + from calibre.utils.webengine import secure_webengine must_use_qt() with lzma.open(P(COMPILER_PATH, allow_user_override=False)) as lzf: @@ -340,7 +340,7 @@ def run_rapydscript_tests(): from calibre.constants import FAKE_HOST, FAKE_PROTOCOL from calibre.gui2 import must_use_qt from calibre.gui2.viewer.web_view import send_reply - from calibre.gui2.webengine import secure_webengine, insert_scripts, create_script + from calibre.utils.webengine import secure_webengine, insert_scripts, create_script must_use_qt() scheme = QWebEngineUrlScheme(FAKE_PROTOCOL.encode('ascii')) scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) diff --git a/src/calibre/utils/webengine.py b/src/calibre/utils/webengine.py new file mode 100644 index 0000000000..54e53ec695 --- /dev/null +++ b/src/calibre/utils/webengine.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2021, Kovid Goyal + + +import json +from qt.core import QObject, pyqtSignal +from qt.webengine import QWebEngineScript, QWebEngineSettings + +from calibre.utils.rapydscript import special_title + + +def secure_webengine(view_or_page_or_settings, for_viewer=False): + s = view_or_page_or_settings.settings() if hasattr( + view_or_page_or_settings, 'settings') else view_or_page_or_settings + a = s.setAttribute + a(QWebEngineSettings.WebAttribute.PluginsEnabled, False) + if not for_viewer: + a(QWebEngineSettings.WebAttribute.JavascriptEnabled, False) + s.setUnknownUrlSchemePolicy(QWebEngineSettings.UnknownUrlSchemePolicy.DisallowUnknownUrlSchemes) + if hasattr(view_or_page_or_settings, 'setAudioMuted'): + view_or_page_or_settings.setAudioMuted(True) + a(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, False) + a(QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, False) + # ensure javascript cannot read from local files + a(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False) + a(QWebEngineSettings.WebAttribute.AllowWindowActivationFromJavaScript, False) + return s + + +def insert_scripts(profile, *scripts): + sc = profile.scripts() + for script in scripts: + for existing in sc.find(script.name()): + sc.remove(existing) + for script in scripts: + sc.insert(script) + + +def create_script( + name, src, world=QWebEngineScript.ScriptWorldId.ApplicationWorld, + injection_point=QWebEngineScript.InjectionPoint.DocumentReady, + on_subframes=True +): + script = QWebEngineScript() + if isinstance(src, bytes): + src = src.decode('utf-8') + script.setSourceCode(src) + script.setName(name) + script.setWorldId(world) + script.setInjectionPoint(injection_point) + script.setRunsOnSubFrames(on_subframes) + return script + + +from_js = pyqtSignal + + +class to_js(str): + + def __call__(self, *a): + print(f'WARNING: Calling {self.name}() before the javascript bridge is ready') + emit = __call__ + + +class to_js_bound(QObject): + + def __init__(self, bridge, name): + QObject.__init__(self, bridge) + self.name = name + + def __call__(self, *args): + self.parent().page.runJavaScript('if (window.python_comm) python_comm._from_python({}, {})'.format( + json.dumps(self.name), json.dumps(args)), QWebEngineScript.ScriptWorldId.ApplicationWorld) + emit = __call__ + + +class Bridge(QObject): + + bridge_ready = pyqtSignal() + + def __init__(self, page): + QObject.__init__(self, page) + self._signals = json.dumps(tuple({k for k, v in self.__class__.__dict__.items() if isinstance(v, pyqtSignal)})) + self._signals_registered = False + page.titleChanged.connect(self._title_changed) + for k, v in self.__class__.__dict__.items(): + if isinstance(v, to_js): + v.name = k + + @property + def page(self): + return self.parent() + + @property + def ready(self): + return self._signals_registered + + def _title_changed(self, title): + if title.startswith(special_title): + self._poll_for_messages() + + def _register_signals(self): + self._signals_registered = True + for k, v in self.__class__.__dict__.items(): + if isinstance(v, to_js): + setattr(self, k, to_js_bound(self, k)) + self.page.runJavaScript('python_comm._register_signals(' + self._signals + ')', QWebEngineScript.ScriptWorldId.ApplicationWorld) + self.bridge_ready.emit() + + def _poll_for_messages(self): + self.page.runJavaScript('python_comm._poll()', QWebEngineScript.ScriptWorldId.ApplicationWorld, self._dispatch_messages) + + def _dispatch_messages(self, messages): + try: + for msg in messages: + if isinstance(msg, dict): + mt = msg.get('type') + if mt == 'signal': + signal = getattr(self, msg['name'], None) + if signal is None: + print('WARNING: No js-to-python signal named: ' + msg['name']) + else: + args = msg['args'] + if args: + signal.emit(*args) + else: + signal.emit() + elif mt == 'qt-ready': + self._register_signals() + except Exception: + if messages: + import traceback + traceback.print_exc()