From bf994cd8daf6c9e23cb01c74f8abbf886e95a881 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 1 Nov 2016 20:43:12 +0100 Subject: [PATCH] Initial QtWebEngine download support --- qutebrowser/browser/downloads.py | 42 +++++- qutebrowser/browser/qtnetworkdownloads.py | 29 +--- .../browser/webengine/webenginedownloads.py | 131 ++++++++++++++++++ qutebrowser/browser/webengine/webenginetab.py | 8 +- 4 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 qutebrowser/browser/webengine/webenginedownloads.py diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index bcaab81a7..db4fd59dc 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -63,6 +63,11 @@ class UnsupportedAttribute: pass +class UnsupportedOperationError(Exception): + + """Raised when an operation is not supported with the given backend.""" + + def download_dir(): """Get the download directory to use.""" directory = config.get('storage', 'download-directory') @@ -396,7 +401,6 @@ class AbstractDownloadItem(QObject): """ self._do_cancel() log.downloads.debug("cancelled") - self.cancelled.emit() if remove_data: self.delete() self.done = True @@ -471,7 +475,15 @@ class AbstractDownloadItem(QObject): """Finish initialization based on self._filename.""" raise NotImplementedError - def set_filename(self, filename): + def _set_fileobj(self, fileobj): + """Set a file object to save the download to. + + Note that some backends (QtWebEngine) will simply access the .name + attribute and not actually use the file object directly. + """ + raise NotImplementedError + + def _set_filename(self, filename): """Set the filename to save the download to. Args: @@ -542,7 +554,23 @@ class AbstractDownloadItem(QObject): Args: target: The usertypes.DownloadTarget for this download. """ - raise NotImplementedError + if isinstance(target, usertypes.FileObjDownloadTarget): + raise UnsupportedAttribute("FileObjDownloadTarget is unsupported") + elif isinstance(target, usertypes.FileDownloadTarget): + self._set_filename(target.filename) + elif isinstance(target, usertypes.OpenFileDownloadTarget): + try: + fobj = temp_download_manager.get_tmpfile(self.basename) + except OSError as exc: + msg = "Download error: {}".format(exc) + message.error(msg) + self.cancel() + return + self.finished.connect( + functools.partial(self._open_if_successful, target.cmdline)) + self._set_fileobj(fobj) + else: # pragma: no cover + raise ValueError("Unsupported download target: {}".format(target)) class AbstractDownloadManager(QObject): @@ -659,6 +687,14 @@ class AbstractDownloadManager(QObject): if first_idx is not None: self.data_changed.emit(first_idx, -1) + def _init_filename_question(self, question, download): + """Set up an existing filename question with a download.""" + question.mode = usertypes.PromptMode.download + question.answered.connect(download.set_target) + question.cancelled.connect(download.cancel) + download.cancelled.connect(question.abort) + download.error.connect(question.abort) + class DownloadModel(QAbstractListModel): diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 38c6a353d..e841f2aca 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -87,9 +87,6 @@ class DownloadItem(downloads.AbstractDownloadItem): self.fileobj = None self.raw_headers = {} - self._filename = None - self._dead = False - self._manager = manager self._retry_info = None self._reply = None @@ -171,6 +168,7 @@ class DownloadItem(downloads.AbstractDownloadItem): self._reply = None if self.fileobj is not None: self.fileobj.close() + self.cancelled.emit() @pyqtSlot() def retry(self): @@ -354,23 +352,8 @@ class DownloadItem(downloads.AbstractDownloadItem): if isinstance(target, usertypes.FileObjDownloadTarget): self._set_fileobj(target.fileobj) self.autoclose = False - elif isinstance(target, usertypes.FileDownloadTarget): - self.set_filename(target.filename) - elif isinstance(target, usertypes.OpenFileDownloadTarget): - try: - fobj = downloads.temp_download_manager.get_tmpfile( - self.basename) - except OSError as exc: - msg = "Download error: {}".format(exc) - message.error(msg) - self.cancel() - return - self.finished.connect( - functools.partial(self._open_if_successful, target.cmdline)) - self.autoclose = True - self._set_fileobj(fobj) - else: # pragma: no cover - raise ValueError("Unsupported download target: {}".format(target)) + else: + super().set_target(target) class DownloadManager(downloads.AbstractDownloadManager): @@ -506,11 +489,7 @@ class DownloadManager(downloads.AbstractDownloadManager): question = downloads.get_filename_question( suggested_filename=suggested_filename, url=reply.url(), parent=self) - question.mode = usertypes.PromptMode.download - question.answered.connect(download.set_target) - question.cancelled.connect(download.cancel) - download.cancelled.connect(question.abort) - download.error.connect(question.abort) + self._init_filename_question(question, download) message.global_bridge.ask(question, blocking=False) return download diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py new file mode 100644 index 000000000..1d1ef3e8e --- /dev/null +++ b/qutebrowser/browser/webengine/webenginedownloads.py @@ -0,0 +1,131 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser 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. +# +# qutebrowser 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 qutebrowser. If not, see . + +"""QtWebEngine specific code for downloads.""" + + +from PyQt5.QtCore import pyqtSlot, Qt +from PyQt5.QtWebEngineWidgets import QWebEngineDownloadItem +from PyQt5.QtWidgets import QApplication + +from qutebrowser.browser import downloads +from qutebrowser.utils import debug, usertypes, message, log + + +class DownloadItem(downloads.AbstractDownloadItem): + + """A wrapper over a QWebEngineDownloadItem. + + Attributes: + _qt_item: The wrapped item. + """ + + def __init__(self, qt_item, parent=None): + super().__init__(parent) + self._qt_item = qt_item + qt_item.downloadProgress.connect(self.stats.on_download_progress) + qt_item.stateChanged.connect(self._on_state_changed) + + @pyqtSlot(QWebEngineDownloadItem.DownloadState) + def _on_state_changed(self, state): + state_name = debug.qenum_key(QWebEngineDownloadItem, state) + log.downloads.debug("State for {!r} changed to {}".format( + self, state_name)) + if state == QWebEngineDownloadItem.DownloadRequested: + pass + elif state == QWebEngineDownloadItem.DownloadInProgress: + pass + elif state == QWebEngineDownloadItem.DownloadCompleted: + self.successful = True + self.done = True + self.finished.emit() + elif state == QWebEngineDownloadItem.DownloadCancelled: + self.successful = False + self.done = True + self.cancelled.emit() + elif state == QWebEngineDownloadItem.DownloadInterrupted: + self.successful = False + self.done = True + # https://bugreports.qt.io/browse/QTBUG-56839 + self.error.emit("Download failed") + else: + raise ValueError("_on_state_changed was called with unknown state " + "{}".format(state_name)) + + def _do_die(self): + self._qt_item.downloadProgress.disconnect() + self._qt_item.cancel() + + def _do_cancel(self): + self._qt_item.cancel() + + def retry(self): + # https://bugreports.qt.io/browse/QTBUG-56840 + raise downloads.UnsupportedOperationError + + def get_open_filename(self): + return self._filename + + def _set_fileobj(self, fileobj): + self._set_filename(fileobj.name) + + def _ensure_can_set_filename(self, filename): + state = self._qt_item.state() + if state != QWebEngineDownloadItem.DownloadRequested: + state_name = debug.qenum_key(QWebEngineDownloadItem, state) + raise ValueError("Trying to set filename {} on {!r} which is state " + "{} (not in requested state)!".format( + filename, self, state_name)) + + def _after_set_filename(self): + self._qt_item.setPath(self._filename) + self._qt_item.accept() + + +class DownloadManager(downloads.AbstractDownloadManager): + + """Manager for currently running downloads.""" + + def install(self, profile): + """Set up the download manager on a QWebEngineProfile.""" + profile.downloadRequested.connect(self.handle_download, + Qt.DirectConnection) + + @pyqtSlot(QWebEngineDownloadItem) + def handle_download(self, qt_item): + download = DownloadItem(qt_item) + self._init_item(download, auto_remove=False, + suggested_filename=qt_item.path()) + + filename = downloads.immediate_download_path() + if filename is not None: + # User doesn't want to be asked, so just use the download_dir + target = usertypes.FileDownloadTarget(filename) + download.set_target(target) + return + + # Ask the user for a filename - needs to be blocking! + question = downloads.get_filename_question( + suggested_filename=qt_item.path(), url=qt_item.url(), + parent=self) + self._init_filename_question(question, download) + + message.global_bridge.ask(question, blocking=True) + # The filename is set via the question.answered signal, connected in + # _init_filename_question. diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 8d1a1ac1c..5f0ea264a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -34,7 +34,8 @@ from PyQt5.QtWebEngineWidgets import (QWebEnginePage, QWebEngineScript, from qutebrowser.browser import browsertab, mouse from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, - interceptor, webenginequtescheme) + interceptor, webenginequtescheme, + webenginedownloads) from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, objreg) @@ -61,6 +62,11 @@ def init(): host_blocker, parent=app) req_interceptor.install(profile) + log.init.debug("Initializing QtWebEngine downloads...") + download_manager = webenginedownloads.DownloadManager(parent=app) + download_manager.install(profile) + objreg.register('webengine-download-manager', download_manager) + # Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs. _JS_WORLD_MAP = {