From 69d20d8fa2836506a68ef436f9dc916654fcab6e Mon Sep 17 00:00:00 2001 From: Mateus Meyer Jiacomelli Date: Thu, 18 May 2023 05:08:43 +0000 Subject: [PATCH] Display Custom Scheme --- openlp/core/api/main.py | 157 +---------------- openlp/core/app.py | 2 + openlp/core/common/mime.py | 175 +++++++++++++++++++ openlp/core/display/html/display.js | 19 +- openlp/core/display/webengine.py | 184 +++++++++++++++++++- openlp/core/display/window.py | 36 ++-- tests/js/test_display.js | 14 +- tests/openlp_core/display/test_webengine.py | 23 +++ tests/openlp_core/display/test_window.py | 14 +- 9 files changed, 436 insertions(+), 188 deletions(-) create mode 100644 openlp/core/common/mime.py create mode 100644 tests/openlp_core/display/test_webengine.py diff --git a/openlp/core/api/main.py b/openlp/core/api/main.py index 64fc53335..44df6e687 100644 --- a/openlp/core/api/main.py +++ b/openlp/core/api/main.py @@ -23,166 +23,11 @@ import os from flask import Blueprint, send_from_directory from openlp.core.common.applocation import AppLocation +from openlp.core.common.mime import get_mime_type main_views = Blueprint('main', __name__) -def get_mime_type(file): - if file.lower().endswith('.aac'): - mime_type = 'audio/aac' - elif file.lower().endswith('.abw'): - mime_type = 'application/x-abiword' - elif file.lower().endswith('.arc'): - mime_type = 'application/x-freearc' - elif file.lower().endswith('.avi'): - mime_type = 'video/x-msvideo' - elif file.lower().endswith('.azw'): - mime_type = 'application/vnd.amazon.ebook' - elif file.lower().endswith('.bin'): - mime_type = 'application/octet-stream' - elif file.lower().endswith('.bmp'): - mime_type = 'image/bmp' - elif file.lower().endswith('.bz'): - mime_type = 'application/x-bzip' - elif file.lower().endswith('.bz2'): - mime_type = 'application/x-bzip2' - elif file.lower().endswith('.cda'): - mime_type = 'application/x-cdf' - elif file.lower().endswith('.csh'): - mime_type = 'application/x-csh' - elif file.lower().endswith('.css'): - mime_type = 'text/css' - elif file.lower().endswith('.csv'): - mime_type = 'text/csv' - elif file.lower().endswith('.doc'): - mime_type = 'application/msword' - elif file.lower().endswith('.docx'): - mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - elif file.lower().endswith('.eot'): - mime_type = 'application/vnd.ms-fontobject' - elif file.lower().endswith('.epub'): - mime_type = 'application/epub+zip' - elif file.lower().endswith('.gz'): - mime_type = 'application/gzip' - elif file.lower().endswith('.gif'): - mime_type = 'image/gif' - elif file.lower().endswith('.htm'): - mime_type = 'text/html' - elif file.lower().endswith('.html'): - mime_type = 'text/html' - elif file.lower().endswith('.ico'): - mime_type = 'image/vnd.microsoft.icon' - elif file.lower().endswith('.ics'): - mime_type = 'text/calendar' - elif file.lower().endswith('.jar'): - mime_type = 'application/java-archive' - elif file.lower().endswith('.jpeg'): - mime_type = 'image/jpeg' - elif file.lower().endswith('.jpg'): - mime_type = 'image/jpeg' - elif file.lower().endswith('.js'): - mime_type = 'application/javascript' - elif file.lower().endswith('.json'): - mime_type = 'application/json' - elif file.lower().endswith('.jsonld'): - mime_type = 'application/ld+json' - elif file.lower().endswith('.mid'): - mime_type = 'audio/midi' - elif file.lower().endswith('.midi'): - mime_type = 'audio/x-midi' - elif file.lower().endswith('.mjs'): - mime_type = 'text/javascript' - elif file.lower().endswith('.mp3'): - mime_type = 'audio/mpeg' - elif file.lower().endswith('.mp4'): - mime_type = 'video/mp4' - elif file.lower().endswith('.mpeg'): - mime_type = 'video/mpeg' - elif file.lower().endswith('.mpkg'): - mime_type = 'application/vnd.apple.installer+xml' - elif file.lower().endswith('.odp'): - mime_type = 'application/vnd.oasis.opendocument.presentation' - elif file.lower().endswith('.ods'): - mime_type = 'application/vnd.oasis.opendocument.spreadsheet' - elif file.lower().endswith('.odt'): - mime_type = 'application/vnd.oasis.opendocument.text' - elif file.lower().endswith('.oga'): - mime_type = 'audio/ogg' - elif file.lower().endswith('.ogv'): - mime_type = 'video/ogg' - elif file.lower().endswith('.ogx'): - mime_type = 'application/ogg' - elif file.lower().endswith('.opus'): - mime_type = 'audio/opus' - elif file.lower().endswith('.otf'): - mime_type = 'application/x-font-opentype' - elif file.lower().endswith('.png'): - mime_type = 'image/png' - elif file.lower().endswith('.pdf'): - mime_type = 'application/pdf' - elif file.lower().endswith('.php'): - mime_type = 'application/x-httpd-php' - elif file.lower().endswith('.ppt'): - mime_type = 'application/vnd.ms-powerpoint' - elif file.lower().endswith('.pptx'): - mime_type = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' - elif file.lower().endswith('.rar'): - mime_type = 'application/vnd.rar' - elif file.lower().endswith('.rtf'): - mime_type = 'application/rtf' - elif file.lower().endswith('.sfnt'): - mime_type = 'application/font-sfnt' - elif file.lower().endswith('.sh'): - mime_type = 'application/x-sh' - elif file.lower().endswith('.svg'): - mime_type = 'image/svg+xml' - elif file.lower().endswith('.swf'): - mime_type = 'application/x-shockwave-flash' - elif file.lower().endswith('.tar'): - mime_type = 'application/x-tar' - elif file.lower().endswith('.tif'): - mime_type = 'image/tiff' - elif file.lower().endswith('.tiff'): - mime_type = 'image/tiff' - elif file.lower().endswith('.ts'): - mime_type = 'video/mp2t' - elif file.lower().endswith('.ttf'): - mime_type = 'application/x-font-ttf' - elif file.lower().endswith('.txt'): - mime_type = 'text/plain' - elif file.lower().endswith('.vsd'): - mime_type = 'application/vnd.visio' - elif file.lower().endswith('.wav'): - mime_type = 'audio/wav' - elif file.lower().endswith('.weba'): - mime_type = 'audio/webm' - elif file.lower().endswith('.webm'): - mime_type = 'video/webm' - elif file.lower().endswith('.webp'): - mime_type = 'image/webp' - elif file.lower().endswith('.woff'): - mime_type = 'application/font-woff' - elif file.lower().endswith('.woff2'): - mime_type = 'application/font-woff2' - elif file.lower().endswith('.xhtml'): - mime_type = 'application/xhtml+xml' - elif file.lower().endswith('.xls'): - mime_type = 'application/vnd.ms-excel' - elif file.lower().endswith('.xlsx'): - mime_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - elif file.lower().endswith('.xml'): - mime_type = 'application/xml' - elif file.lower().endswith('.xul'): - mime_type = 'application/vnd.mozilla.xul+xml' - elif file.lower().endswith('.zip'): - mime_type = 'application/zip' - elif file.lower().endswith('.7z'): - mime_type = 'application/x-7z-compressed' - else: - mime_type = 'application/octet-stream' - return mime_type - - @main_views.route('/', defaults={'path': ''}) @main_views.route('/') def index(path): diff --git a/openlp/core/app.py b/openlp/core/app.py index 1fdd0cbc8..9aaf97ed4 100644 --- a/openlp/core/app.py +++ b/openlp/core/app.py @@ -46,6 +46,7 @@ from openlp.core.common.platform import is_macosx, is_win from openlp.core.common.registry import Registry from openlp.core.common.settings import Settings from openlp.core.display.screens import ScreenList +from openlp.core.display.webengine import init_webview_custom_schemes from openlp.core.loader import loader from openlp.core.resources import qInitResources from openlp.core.server import Server @@ -414,6 +415,7 @@ def main(): qInitResources() # Now create and actually run the application. QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + init_webview_custom_schemes() application = QtWidgets.QApplication(qt_args) application.setOrganizationName('OpenLP') application.setOrganizationDomain('openlp.org') diff --git a/openlp/core/common/mime.py b/openlp/core/common/mime.py new file mode 100644 index 000000000..7aa0454fe --- /dev/null +++ b/openlp/core/common/mime.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2023 OpenLP Developers # +# ---------------------------------------------------------------------- # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +########################################################################## + +def get_mime_type(file): + if file.lower().endswith('.aac'): + mime_type = 'audio/aac' + elif file.lower().endswith('.abw'): + mime_type = 'application/x-abiword' + elif file.lower().endswith('.arc'): + mime_type = 'application/x-freearc' + elif file.lower().endswith('.avi'): + mime_type = 'video/x-msvideo' + elif file.lower().endswith('.azw'): + mime_type = 'application/vnd.amazon.ebook' + elif file.lower().endswith('.bin'): + mime_type = 'application/octet-stream' + elif file.lower().endswith('.bmp'): + mime_type = 'image/bmp' + elif file.lower().endswith('.bz'): + mime_type = 'application/x-bzip' + elif file.lower().endswith('.bz2'): + mime_type = 'application/x-bzip2' + elif file.lower().endswith('.cda'): + mime_type = 'application/x-cdf' + elif file.lower().endswith('.csh'): + mime_type = 'application/x-csh' + elif file.lower().endswith('.css'): + mime_type = 'text/css' + elif file.lower().endswith('.csv'): + mime_type = 'text/csv' + elif file.lower().endswith('.doc'): + mime_type = 'application/msword' + elif file.lower().endswith('.docx'): + mime_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + elif file.lower().endswith('.eot'): + mime_type = 'application/vnd.ms-fontobject' + elif file.lower().endswith('.epub'): + mime_type = 'application/epub+zip' + elif file.lower().endswith('.gz'): + mime_type = 'application/gzip' + elif file.lower().endswith('.gif'): + mime_type = 'image/gif' + elif file.lower().endswith('.htm'): + mime_type = 'text/html' + elif file.lower().endswith('.html'): + mime_type = 'text/html' + elif file.lower().endswith('.ico'): + mime_type = 'image/vnd.microsoft.icon' + elif file.lower().endswith('.ics'): + mime_type = 'text/calendar' + elif file.lower().endswith('.jar'): + mime_type = 'application/java-archive' + elif file.lower().endswith('.jpeg'): + mime_type = 'image/jpeg' + elif file.lower().endswith('.jpg'): + mime_type = 'image/jpeg' + elif file.lower().endswith('.js'): + mime_type = 'application/javascript' + elif file.lower().endswith('.json'): + mime_type = 'application/json' + elif file.lower().endswith('.jsonld'): + mime_type = 'application/ld+json' + elif file.lower().endswith('.mid'): + mime_type = 'audio/midi' + elif file.lower().endswith('.midi'): + mime_type = 'audio/x-midi' + elif file.lower().endswith('.mjs'): + mime_type = 'text/javascript' + elif file.lower().endswith('.mp3'): + mime_type = 'audio/mpeg' + elif file.lower().endswith('.mp4'): + mime_type = 'video/mp4' + elif file.lower().endswith('.mpeg'): + mime_type = 'video/mpeg' + elif file.lower().endswith('.mpkg'): + mime_type = 'application/vnd.apple.installer+xml' + elif file.lower().endswith('.odp'): + mime_type = 'application/vnd.oasis.opendocument.presentation' + elif file.lower().endswith('.ods'): + mime_type = 'application/vnd.oasis.opendocument.spreadsheet' + elif file.lower().endswith('.odt'): + mime_type = 'application/vnd.oasis.opendocument.text' + elif file.lower().endswith('.oga'): + mime_type = 'audio/ogg' + elif file.lower().endswith('.ogv'): + mime_type = 'video/ogg' + elif file.lower().endswith('.ogx'): + mime_type = 'application/ogg' + elif file.lower().endswith('.opus'): + mime_type = 'audio/opus' + elif file.lower().endswith('.otf'): + mime_type = 'application/x-font-opentype' + elif file.lower().endswith('.png'): + mime_type = 'image/png' + elif file.lower().endswith('.pdf'): + mime_type = 'application/pdf' + elif file.lower().endswith('.php'): + mime_type = 'application/x-httpd-php' + elif file.lower().endswith('.ppt'): + mime_type = 'application/vnd.ms-powerpoint' + elif file.lower().endswith('.pptx'): + mime_type = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + elif file.lower().endswith('.rar'): + mime_type = 'application/vnd.rar' + elif file.lower().endswith('.rtf'): + mime_type = 'application/rtf' + elif file.lower().endswith('.sfnt'): + mime_type = 'application/font-sfnt' + elif file.lower().endswith('.sh'): + mime_type = 'application/x-sh' + elif file.lower().endswith('.svg'): + mime_type = 'image/svg+xml' + elif file.lower().endswith('.swf'): + mime_type = 'application/x-shockwave-flash' + elif file.lower().endswith('.tar'): + mime_type = 'application/x-tar' + elif file.lower().endswith('.tif'): + mime_type = 'image/tiff' + elif file.lower().endswith('.tiff'): + mime_type = 'image/tiff' + elif file.lower().endswith('.ts'): + mime_type = 'video/mp2t' + elif file.lower().endswith('.ttf'): + mime_type = 'application/x-font-ttf' + elif file.lower().endswith('.txt'): + mime_type = 'text/plain' + elif file.lower().endswith('.vsd'): + mime_type = 'application/vnd.visio' + elif file.lower().endswith('.wav'): + mime_type = 'audio/wav' + elif file.lower().endswith('.weba'): + mime_type = 'audio/webm' + elif file.lower().endswith('.webm'): + mime_type = 'video/webm' + elif file.lower().endswith('.webp'): + mime_type = 'image/webp' + elif file.lower().endswith('.woff'): + mime_type = 'application/font-woff' + elif file.lower().endswith('.woff2'): + mime_type = 'application/font-woff2' + elif file.lower().endswith('.xhtml'): + mime_type = 'application/xhtml+xml' + elif file.lower().endswith('.xls'): + mime_type = 'application/vnd.ms-excel' + elif file.lower().endswith('.xlsx'): + mime_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + elif file.lower().endswith('.xml'): + mime_type = 'application/xml' + elif file.lower().endswith('.xul'): + mime_type = 'application/vnd.mozilla.xul+xml' + elif file.lower().endswith('.zip'): + mime_type = 'application/zip' + elif file.lower().endswith('.7z'): + mime_type = 'application/x-7z-compressed' + else: + mime_type = 'application/octet-stream' + return mime_type diff --git a/openlp/core/display/html/display.js b/openlp/core/display/html/display.js index a95244cde..4fda6a3ef 100644 --- a/openlp/core/display/html/display.js +++ b/openlp/core/display/html/display.js @@ -456,7 +456,7 @@ var Display = { section.setAttribute("data-background", bg_color); section.setAttribute("style", "height: 100%; width: 100%;"); var img = document.createElement('img'); - img.src = image; + img.src = Display._getFileUrl(image); img.setAttribute("style", "position: absolute; top: 0; bottom: 0; left: 0; right: 0; margin: auto; max-height: 100%; max-width: 100%"); section.appendChild(img); Display._slides['0'] = 0; @@ -734,7 +734,7 @@ var Display = { section.setAttribute("id", index); section.setAttribute("style", "height: 100%; width: 100%;"); var img = document.createElement('img'); - img.src = slide.path; + img.src = Display._getFileUrl(slide.path); img.setAttribute("style", "width: 100%; height: 100%; margin: 0; object-fit: contain;"); img.setAttribute('data-slide', index); section.appendChild(img); @@ -1211,12 +1211,12 @@ var Display = { } break; case BackgroundType.Image: - backgroundContent = "url('" + Display._theme.background_filename + "')"; + backgroundContent = "url('" + Display._getFileUrl(Display._theme.background_filename) + "')"; break; case BackgroundType.Video: // never actually used since background type is overridden from video to transparent in window.py backgroundContent = Display._theme.background_border_color; - backgroundHtml = ""; + backgroundHtml = ""; break; default: backgroundContent = "#000"; @@ -1393,7 +1393,16 @@ var Display = { value[1] = '/'; value[2] = Object.keys(Display._slides).length; return value; - } + }, + /** + * Translates file:// protocol URLs to openlp-library://local-file/ scheme + */ + _getFileUrl: function(url) { + if (url && (url.indexOf('file://') === 0)) { + return url.replace('file://', 'openlp-library://local-file/'); + } + return url; + }, }; new QWebChannel(qt.webChannelTransport, function (channel) { window.displayWatcher = channel.objects.displayWatcher; diff --git a/openlp/core/display/webengine.py b/openlp/core/display/webengine.py index a06467eef..9ff4872bf 100644 --- a/openlp/core/display/webengine.py +++ b/openlp/core/display/webengine.py @@ -23,10 +23,15 @@ Subclass of QWebEngineView. Adds some special eventhandling needed for screensho Heavily inspired by https://stackoverflow.com/questions/33467776/qt-qwebengine-render-after-scrolling/33576100#33576100 """ import logging +import os.path -from PyQt5 import QtCore, QtWebEngineWidgets, QtWidgets +from PyQt5 import QtCore, QtWebEngineWidgets, QtWidgets, QtWebEngineCore +from typing import Tuple +from openlp.core.common import Singleton from openlp.core.common.applocation import AppLocation +from openlp.core.common.mime import get_mime_type +from openlp.core.common.platform import is_win LOG_LEVELS = { @@ -107,3 +112,180 @@ class WebEngineView(QtWebEngineWidgets.QWebEngineView): self._child = w w.installEventFilter(self) return super(WebEngineView, self).event(ev) + + +class LocalSchemeHelper(): + def deny_access(self, request: QtWebEngineCore.QWebEngineUrlRequestJob, url: str, real_path: str): + log.exception('{request} denied. Real Path: {path}'.format(request=url, + path=real_path)) + request.fail(QtWebEngineCore.QWebEngineUrlRequestJob.Error.RequestDenied) + + def not_found(self, request: QtWebEngineCore.QWebEngineUrlRequestJob, url: str, real_path: str): + log.exception('{request} not found. Real Path: {path}'.format(request=url, + path=real_path)) + request.fail(QtWebEngineCore.QWebEngineUrlRequestJob.Error.UrlNotFound) + + def exception(self, request: QtWebEngineCore.QWebEngineUrlRequestJob, url: str, exception: Exception): + log.exception('{request} failed: {error:d}; {message}'.format(request=url, error=str(type(exception)), + message=str(exception))) + request.fail(QtWebEngineCore.QWebEngineUrlRequestJob.Error.RequestFailed) + + def invalid(self, request: QtWebEngineCore.QWebEngineUrlRequestJob, url: str): + log.exception('{request} is invalid.'.format(request=url)) + request.fail(QtWebEngineCore.QWebEngineUrlRequestJob.Error.UrlInvalid) + + def reply_file(self, request: QtWebEngineCore.QWebEngineUrlRequestJob, path: str) -> Tuple[QtCore.QBuffer, str]: + raw_html = open(path, 'rb').read() + buffer = QtCore.QBuffer(self) + buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) + buffer.write(raw_html) + buffer.seek(0) + buffer.close() + mime_type = get_mime_type(path) + request.reply(bytes(mime_type, 'utf-8'), buffer) + + def request_path_to_file(self, base_path: str, url_path: str): + if is_win() and not base_path: + while len(url_path) > 0 and url_path[0] == '/': + url_path = url_path[1:] + return os.path.realpath(base_path + os.path.normpath(url_path)) + + +class OpenLPSchemeHandler(QtWebEngineCore.QWebEngineUrlSchemeHandler, LocalSchemeHelper): + def __init__(self, root_paths, parent): + super().__init__(parent) + self.root_paths = {base_path: os.path.realpath(value) for base_path, value in root_paths.items()} + + def requestStarted(self, request: QtWebEngineCore.QWebEngineUrlRequestJob) -> None: + url = request.requestUrl() + try: + request_base_key = url.host() + # Checking root to forbid relative path attacks + base_real_path = self.root_paths[request_base_key] + request_real_path = self.request_path_to_file(base_real_path, url.path()) + if not request_real_path.startswith(base_real_path): + return self.deny_access(request, url, request_real_path) + if not os.path.exists(request_real_path): + return self.not_found(request, url, request_real_path) + self.reply_file(request, request_real_path) + return + except Exception as exception: + self.exception(request, url, exception) + + +class OpenLPLibrarySchemeHandler(QtWebEngineCore.QWebEngineUrlSchemeHandler, LocalSchemeHelper): + def __init__(self, parent): + super().__init__(parent) + + def requestStarted(self, request: QtWebEngineCore.QWebEngineUrlRequestJob) -> None: + url = request.requestUrl() + try: + request_base_key = url.host() + if request_base_key == 'local-file': + # Checking root to forbid relative path attacks + request_real_path = self.request_path_to_file('', url.path()) + if not os.path.exists(request_real_path): + return self.not_found(request, url, request_real_path) + return self.reply_file(request, request_real_path) + return self.invalid(request, url) + except Exception as exception: + self.exception(request, url, exception) + + +class WebViewCustomScheme(QtCore.QObject): + """ + Allows the registering of custom protocols inside WebView. Can be used mainly for + circumventing default security measures placed on http(s):// or file:// protocols. + """ + + def __init__(self, parent): + super().__init__(parent) + scheme = QtWebEngineCore.QWebEngineUrlScheme(self.scheme_name) + scheme.setSyntax(QtWebEngineCore.QWebEngineUrlScheme.Syntax.Host) + self.set_scheme_flags(scheme) + QtWebEngineCore.QWebEngineUrlScheme.registerScheme(scheme) + + def set_scheme_flags(self, scheme: QtWebEngineCore.QWebEngineUrlScheme): + scheme.setFlags(QtWebEngineCore.QWebEngineUrlScheme.Flag.CorsEnabled + | QtWebEngineCore.QWebEngineUrlScheme.Flag.ContentSecurityPolicyIgnored + | QtWebEngineCore.QWebEngineUrlScheme.Flag.SecureScheme + | QtWebEngineCore.QWebEngineUrlScheme.Flag.LocalScheme + | QtWebEngineCore.QWebEngineUrlScheme.Flag.LocalAccessAllowed) + + def create_scheme_handler(self): + raise Exception('Needs to be implemented.') + + def init_handler(self, profile=None): + if profile is None: + profile = QtWebEngineWidgets.QWebEngineProfile.defaultProfile() + handler = profile.urlSchemeHandler(self.scheme_name) + if handler is not None: + profile.removeUrlSchemeHandler(handler) + self.handler = self.create_scheme_handler() + profile.installUrlSchemeHandler(self.scheme_name, self.handler) + + +class OpenLPScheme(WebViewCustomScheme): + scheme_name = b'openlp' + + def __init__(self, root_paths, parent=None): + super().__init__(parent) + self.root_paths = root_paths + + def create_scheme_handler(self): + return OpenLPSchemeHandler(self.root_paths, self) + + def set_root_path(self, base: str, path: str): + if hasattr(self, 'handler'): + self.handler.root_paths[base] = path + self.root_paths[base] = path + + +# Allows to circumvent default file-on-HTTP restrictions and/or provide remote/non-local file locations. +class OpenLPLibraryScheme(WebViewCustomScheme): + scheme_name = b'openlp-library' + + def __init__(self, parent=None): + super().__init__(parent) + + def set_scheme_flags(self, scheme: QtWebEngineCore.QWebEngineUrlScheme): + scheme.setFlags(QtWebEngineCore.QWebEngineUrlScheme.Flag.ContentSecurityPolicyIgnored) + + def create_scheme_handler(self): + return OpenLPLibrarySchemeHandler(self) + + +def init_webview_custom_schemes(): + """ + This inits the custom scheme protocols used in OpenLP WebEngines. It must happen before + QApplication instantiation. + """ + openlp_root_paths = { + "display": AppLocation.get_directory(AppLocation.AppDir) / 'core' / 'display' / 'html' + } + # openlp:// protocol + WebViewSchemes().register_scheme(OpenLPScheme(openlp_root_paths)) + # openlp-library:// protocol + WebViewSchemes().register_scheme(OpenLPLibraryScheme()) + + +def set_webview_display_path(path): + scheme = WebViewSchemes().get_scheme(OpenLPScheme.scheme_name) + if (scheme): + scheme.set_root_path('display', path) + + +class WebViewSchemes(metaclass=Singleton): + def __init__(self) -> None: + self._registered_schemes = {} + + def get_scheme(self, name) -> WebViewCustomScheme: + if isinstance(name, bytes): + name = name.decode('utf-8') + return self._registered_schemes[name] if name in self._registered_schemes else None + + def register_scheme(self, scheme: WebViewCustomScheme): + name = scheme.scheme_name + if isinstance(name, bytes): + name = name.decode('utf-8') + self._registered_schemes[name] = scheme diff --git a/openlp/core/display/window.py b/openlp/core/display/window.py index d1a9f9c84..28d04576b 100644 --- a/openlp/core/display/window.py +++ b/openlp/core/display/window.py @@ -29,7 +29,6 @@ import re from PyQt5 import QtCore, QtWebChannel, QtWidgets -from openlp.core.common.applocation import AppLocation from openlp.core.common.enum import ServiceItemType from openlp.core.common.i18n import translate from openlp.core.common.mixins import LogMixin, RegistryProperties @@ -146,8 +145,6 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin): self.setAttribute(QtCore.Qt.WidgetAttribute.WA_X11NetWmWindowTypeDialog) if is_macosx(): self.setAttribute(QtCore.Qt.WA_MacAlwaysShowToolWindow, True) - # Need to import this inline to get around a QtWebEngine issue - from openlp.core.display.webengine import WebEngineView self._is_initialised = False self._is_manual_close = False self._can_show_startup_screen = can_show_startup_screen @@ -159,22 +156,22 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin): self.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.layout = QtWidgets.QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) - self.webview = WebEngineView(self) + self.webview = self.init_webengine() self.webview.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.webview.page().setBackgroundColor(QtCore.Qt.transparent) self.webview.display_clicked = self.disable_display self.layout.addWidget(self.webview) self.webview.loadFinished.connect(self.after_loaded) - display_base_path = AppLocation.get_directory(AppLocation.AppDir) / 'core' / 'display' / 'html' - self.display_path = display_base_path / 'display.html' - self.checkerboard_path = display_base_path / 'checkerboard.png' - self.openlp_splash_screen_path = display_base_path / 'openlp-splash-screen.png' + self.display_path = 'openlp://display/display.html' + self.checkerboard_path = 'openlp://display/checkerboard.png' + self.openlp_splash_screen_path = 'openlp://display/openlp-splash-screen.png' self.channel = QtWebChannel.QWebChannel(self) self.display_watcher = DisplayWatcher(self) self.channel.registerObject('displayWatcher', self.display_watcher) self.webview.page().setWebChannel(self.channel) self.display_watcher.initialised.connect(self.on_initialised) - self.set_url(QtCore.QUrl.fromLocalFile(path_to_str(self.display_path))) + qUrl = QtCore.QUrl(self.display_path) + self.set_url(qUrl) self.is_display = False self.scale = 1 self.hide_mode = None @@ -198,6 +195,16 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin): if not self._is_manual_close: event.ignore() + def init_webengine(self): + # Need to import this inline to get around a QtWebEngine issue + from openlp.core.display.webengine import WebEngineView, WebViewSchemes, OpenLPScheme, OpenLPLibraryScheme + webview = WebEngineView(self) + profile = webview.page().profile() + WebViewSchemes().get_scheme(OpenLPScheme.scheme_name).init_handler(profile) + WebViewSchemes().get_scheme(OpenLPLibraryScheme.scheme_name).init_handler(profile) + + return webview + def _fix_font_name(self, font_name): """ Do some font machinations to see if we can fix the font name @@ -260,11 +267,12 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin): bg_color = self.settings.value('core/logo background color') image = self.settings.value('core/logo file') if path_to_str(image).startswith(':'): - image = self.openlp_splash_screen_path - try: - image_uri = image.as_uri() - except Exception: - image_uri = '' + image_uri = self.openlp_splash_screen_path + else: + try: + image_uri = image.as_uri().replace('file://', 'openlp-library://local-file/') + except Exception: + image_uri = '' # if set to hide logo on startup, do not send the logo if self.settings.value('core/logo hide on startup'): image_uri = '' diff --git a/tests/js/test_display.js b/tests/js/test_display.js index d40400bff..e2705eeab 100644 --- a/tests/js/test_display.js +++ b/tests/js/test_display.js @@ -987,7 +987,7 @@ describe("Display.setImageSlides", function () { }); it("should add a list of images", function () { - var slides = [{"path": "file:///openlp1.jpg"}, {"path": "file:///openlp2.jpg"}]; + var slides = [{"path": "openlp-library://local-file//openlp1.jpg"}, {"path": "openlp-library://local-file//openlp2.jpg"}]; spyOn(Reveal, "sync"); spyOn(Reveal, "slide"); @@ -997,9 +997,9 @@ describe("Display.setImageSlides", function () { expect(Display._slides["1"]).toEqual(1); expect($(".slides > section > section").length).toEqual(2); expect($(".slides > section > section > img").length).toEqual(2); - expect($(".slides > section > section > img")[0].getAttribute("src")).toEqual("file:///openlp1.jpg"); + expect($(".slides > section > section > img")[0].getAttribute("src")).toEqual("openlp-library://local-file//openlp1.jpg"); expect($(".slides > section > section > img")[0].getAttribute("style")).toEqual("width: 100%; height: 100%; margin: 0; object-fit: contain;"); - expect($(".slides > section > section > img")[1].getAttribute("src")).toEqual("file:///openlp2.jpg"); + expect($(".slides > section > section > img")[1].getAttribute("src")).toEqual("openlp-library://local-file//openlp2.jpg"); expect($(".slides > section > section > img")[1].getAttribute("style")).toEqual("width: 100%; height: 100%; margin: 0; object-fit: contain;"); expect(Reveal.sync).toHaveBeenCalledTimes(1); }); @@ -1210,6 +1210,14 @@ describe("Display.toggleVideoMute", function () { }); }); +describe("localFile", function() { + it('should translate file:// protocol to openlp-library://local-file/ scheme', function() { + const fileUrl = 'file:///home/path/to/image.png'; + const resultFile = Display._getFileUrl(fileUrl); + expect(resultFile).toEqual('openlp-library://local-file//home/path/to/image.png'); + }) +}) + describe("Reveal slidechanged event", function () { it("should swap footer content", function (done) { var slides = [ diff --git a/tests/openlp_core/display/test_webengine.py b/tests/openlp_core/display/test_webengine.py new file mode 100644 index 000000000..bb46076fa --- /dev/null +++ b/tests/openlp_core/display/test_webengine.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +########################################################################## +# OpenLP - Open Source Lyrics Projection # +# ---------------------------------------------------------------------- # +# Copyright (c) 2008-2023 OpenLP Developers # +# ---------------------------------------------------------------------- # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +########################################################################## +""" +Package to test the openlp.core.display.webengine package. +""" diff --git a/tests/openlp_core/display/test_window.py b/tests/openlp_core/display/test_window.py index 786e918c5..f066311cb 100644 --- a/tests/openlp_core/display/test_window.py +++ b/tests/openlp_core/display/test_window.py @@ -226,7 +226,7 @@ def test_set_startup_screen(display_window_env, mock_settings): # THEN: javascript should be run display_window.run_javascript.assert_called_once_with( - 'Display.setStartupSplashScreen("red", "file://{path}");'.format(path=expect_image_path)) + 'Display.setStartupSplashScreen("red", "openlp-library://local-file/{path}");'.format(path=expect_image_path)) def test_set_startup_screen_default_image(display_window_env, mock_settings): @@ -237,13 +237,9 @@ def test_set_startup_screen_default_image(display_window_env, mock_settings): display_window = DisplayWindow() display_window._is_initialised = True display_window.run_javascript = MagicMock() - if is_win(): - splash_screen_path = 'c:/default/splash_screen.png' - expect_splash_screen_path = '/' + splash_screen_path - else: - splash_screen_path = '/default/splash_screen.png' - expect_splash_screen_path = splash_screen_path - display_window.openlp_splash_screen_path = Path(splash_screen_path) + splash_screen_path = 'openlp://display/openlp-splash-screen.png' + expect_splash_screen_path = splash_screen_path + display_window.openlp_splash_screen_path = splash_screen_path settings = { 'core/logo background color': 'blue', 'core/logo file': Path(':/graphics/openlp-splash-screen.png'), @@ -256,7 +252,7 @@ def test_set_startup_screen_default_image(display_window_env, mock_settings): # THEN: javascript should be run display_window.run_javascript.assert_called_with( - 'Display.setStartupSplashScreen("blue", "file://{path}");'.format(path=expect_splash_screen_path)) + 'Display.setStartupSplashScreen("blue", "{path}");'.format(path=expect_splash_screen_path)) def test_set_startup_screen_missing(display_window_env, mock_settings):