Avoid starting downloads before we know the filename.

Closes #278.
This commit is contained in:
Florian Bruhin 2014-11-30 18:07:44 +01:00
parent 3124d9ce33
commit 6d419b8346
6 changed files with 133 additions and 46 deletions

View File

@ -490,6 +490,16 @@ class DownloadManager(QAbstractListModel):
def __repr__(self): def __repr__(self):
return utils.get_repr(self, downloads=len(self.downloads)) return utils.get_repr(self, downloads=len(self.downloads))
def _prepare_question(self):
"""Prepare a Question object to be asked."""
q = usertypes.Question(self)
q.text = "Save file to:"
q.mode = usertypes.PromptMode.text
q.completed.connect(q.deleteLater)
q.destroyed.connect(functools.partial(self.questions.remove, q))
self.questions.append(q)
return q
@cmdutils.register(instance='download-manager', scope='window') @cmdutils.register(instance='download-manager', scope='window')
def download(self, url, dest=None): def download(self, url, dest=None):
"""Download a given URL, given as string. """Download a given URL, given as string.
@ -513,7 +523,10 @@ class DownloadManager(QAbstractListModel):
filename: A path to write the data to. filename: A path to write the data to.
Return: Return:
The created DownloadItem. If the download could start immediately, (fileobj/filename given),
the created DownloadItem.
If not, None.
""" """
if fileobj is not None and filename is not None: if fileobj is not None and filename is not None:
raise TypeError("Only one of fileobj/filename may be given!") raise TypeError("Only one of fileobj/filename may be given!")
@ -521,32 +534,59 @@ class DownloadManager(QAbstractListModel):
urlutils.invalid_url_error(self._win_id, url, "start download") urlutils.invalid_url_error(self._win_id, url, "start download")
return return
req = QNetworkRequest(url) req = QNetworkRequest(url)
return self.get_request(req, page, fileobj, filename)
def get_request(self, request, page=None, fileobj=None, filename=None):
"""Start a download with a QNetworkRequest.
Args:
request: The QNetworkRequest to download.
page: The QWebPage to use.
fileobj: The file object to write the answer to.
filename: A path to write the data to.
Return:
If the download could start immediately, (fileobj/filename given),
the created DownloadItem.
If not, None.
"""
if fileobj is not None and filename is not None:
raise TypeError("Only one of fileobj/filename may be given!")
# WORKAROUND for Qt corrupting data loaded from cache: # WORKAROUND for Qt corrupting data loaded from cache:
# https://bugreports.qt-project.org/browse/QTBUG-42757 # https://bugreports.qt-project.org/browse/QTBUG-42757
req.setAttribute(QNetworkRequest.CacheLoadControlAttribute, request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
QNetworkRequest.AlwaysNetwork) QNetworkRequest.AlwaysNetwork)
if fileobj is not None or filename is not None:
return self.fetch_request(request, filename, fileobj, page)
q = self._prepare_question()
q.default = urlutils.filename_from_url(request.url())
message_bridge = objreg.get('message-bridge', scope='window',
window=self._win_id)
q.answered.connect(
lambda fn: self.fetch_request(request, filename=fn, page=page))
message_bridge.ask(q, blocking=False)
return None
def fetch_request(self, request, page=None, fileobj=None, filename=None):
"""Download a QNetworkRequest to disk.
Args:
request: The QNetworkRequest to download.
page: The QWebPage to use.
fileobj: The file object to write the answer to.
filename: A path to write the data to.
Return:
The created DownloadItem.
"""
if page is None: if page is None:
nam = self._networkmanager nam = self._networkmanager
else: else:
nam = page.networkAccessManager() nam = page.networkAccessManager()
reply = nam.get(req) reply = nam.get(request)
return self.fetch(reply, fileobj, filename) return self.fetch(reply, fileobj, filename)
@cmdutils.register(instance='download-manager', scope='window')
def cancel_download(self, count: {'special': 'count'}=1):
"""Cancel the first/[count]th download.
Args:
count: The index of the download to cancel.
"""
if count == 0:
return
try:
download = self.downloads[count - 1]
except IndexError:
raise cmdexc.CommandError("There's no download {}!".format(count))
download.cancel()
@pyqtSlot('QNetworkReply') @pyqtSlot('QNetworkReply')
def fetch(self, reply, fileobj=None, filename=None): def fetch(self, reply, fileobj=None, filename=None):
"""Download a QNetworkReply to disk. """Download a QNetworkReply to disk.
@ -589,15 +629,10 @@ class DownloadManager(QAbstractListModel):
download.set_fileobj(fileobj) download.set_fileobj(fileobj)
download.autoclose = False download.autoclose = False
else: else:
q = usertypes.Question(self) q = self._prepare_question()
q.text = "Save file to:"
q.mode = usertypes.PromptMode.text
q.default = suggested_filename q.default = suggested_filename
q.answered.connect(download.set_filename) q.answered.connect(download.set_filename)
q.cancelled.connect(download.cancel) q.cancelled.connect(download.cancel)
q.completed.connect(q.deleteLater)
q.destroyed.connect(functools.partial(self.questions.remove, q))
self.questions.append(q)
download.cancelled.connect(q.abort) download.cancelled.connect(q.abort)
download.error.connect(q.abort) download.error.connect(q.abort)
message_bridge = objreg.get('message-bridge', scope='window', message_bridge = objreg.get('message-bridge', scope='window',
@ -606,6 +641,21 @@ class DownloadManager(QAbstractListModel):
return download return download
@cmdutils.register(instance='download-manager', scope='window')
def cancel_download(self, count: {'special': 'count'}=1):
"""Cancel the first/[count]th download.
Args:
count: The index of the download to cancel.
"""
if count == 0:
return
try:
download = self.downloads[count - 1]
except IndexError:
raise cmdexc.CommandError("There's no download {}!".format(count))
download.cancel()
@pyqtSlot(QNetworkRequest, QNetworkReply) @pyqtSlot(QNetworkRequest, QNetworkReply)
def on_redirect(self, download, request, reply): def on_redirect(self, download, request, reply):
"""Handle a HTTP redirect of a download. """Handle a HTTP redirect of a download.

View File

@ -21,9 +21,9 @@
import functools import functools
from PyQt5.QtCore import pyqtSignal, pyqtSlot, PYQT_VERSION, Qt, QUrl from PyQt5.QtCore import pyqtSlot, PYQT_VERSION, Qt, QUrl
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from PyQt5.QtWidgets import QFileDialog from PyQt5.QtWidgets import QFileDialog
from PyQt5.QtPrintSupport import QPrintDialog from PyQt5.QtPrintSupport import QPrintDialog
from PyQt5.QtWebKitWidgets import QWebPage from PyQt5.QtWebKitWidgets import QWebPage
@ -42,13 +42,8 @@ class BrowserPage(QWebPage):
_extension_handlers: Mapping of QWebPage extensions to their handlers. _extension_handlers: Mapping of QWebPage extensions to their handlers.
_networkmnager: The NetworkManager used. _networkmnager: The NetworkManager used.
_win_id: The window ID this BrowserPage is associated with. _win_id: The window ID this BrowserPage is associated with.
Signals:
start_download: Emitted when a file should be downloaded.
""" """
start_download = pyqtSignal('QNetworkReply*')
def __init__(self, win_id, parent=None): def __init__(self, win_id, parent=None):
super().__init__(parent) super().__init__(parent)
self._win_id = win_id self._win_id = win_id
@ -166,9 +161,17 @@ class BrowserPage(QWebPage):
@pyqtSlot('QNetworkRequest') @pyqtSlot('QNetworkRequest')
def on_download_requested(self, request): def on_download_requested(self, request):
"""Called when the user wants to download a link.""" """Called when the user wants to download a link.
reply = self.networkAccessManager().get(request)
self.start_download.emit(reply) We need to construct a copy of the QNetworkRequest here as the
download_manager needs it async and we'd get a segfault otherwise as
soon as the user has entered the filename, as Qt seems to delete it
after this slot returns.
"""
req = QNetworkRequest(request)
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
download_manager.get_request(req, page=self)
@pyqtSlot('QNetworkReply') @pyqtSlot('QNetworkReply')
def on_unsupported_content(self, reply): def on_unsupported_content(self, reply):
@ -181,9 +184,11 @@ class BrowserPage(QWebPage):
here: http://mimesniff.spec.whatwg.org/ here: http://mimesniff.spec.whatwg.org/
""" """
inline, _suggested_filename = http.parse_content_disposition(reply) inline, _suggested_filename = http.parse_content_disposition(reply)
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
if not inline: if not inline:
# Content-Disposition: attachment -> force download # Content-Disposition: attachment -> force download
self.start_download.emit(reply) download_manager.fetch(reply)
return return
mimetype, _rest = http.parse_content_type(reply) mimetype, _rest = http.parse_content_type(reply)
if mimetype == 'image/jpg': if mimetype == 'image/jpg':
@ -198,7 +203,7 @@ class BrowserPage(QWebPage):
self.display_content, reply, 'image/jpeg')) self.display_content, reply, 'image/jpeg'))
else: else:
# Unknown mimetype, so download anyways. # Unknown mimetype, so download anyways.
self.start_download.emit(reply) download_manager.fetch(reply)
def userAgentForUrl(self, url): def userAgentForUrl(self, url):
"""Override QWebPage::userAgentForUrl to customize the user agent.""" """Override QWebPage::userAgentForUrl to customize the user agent."""

View File

@ -206,5 +206,25 @@ class QurlFromUserInputTests(unittest.TestCase):
'http://[::1]') 'http://[::1]')
class FilenameFromUrlTests(unittest.TestCase):
"""Tests for filename_from_url."""
def test_invalid_url(self):
"""Test with an invalid QUrl."""
self.assertEqual(urlutils.filename_from_url(QUrl()), None)
def test_url_path(self):
"""Test with an URL with path."""
url = QUrl('http://qutebrowser.org/test.html')
self.assertEqual(urlutils.filename_from_url(url), 'test.html')
def test_url_host(self):
"""Test with an URL with no path."""
url = QUrl('http://qutebrowser.org/')
self.assertEqual(urlutils.filename_from_url(url),
'qutebrowser.org.html')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -22,6 +22,7 @@
import re import re
import os.path import os.path
import ipaddress import ipaddress
import posixpath
import urllib.parse import urllib.parse
from PyQt5.QtCore import QUrl from PyQt5.QtCore import QUrl
@ -288,6 +289,26 @@ def raise_cmdexc_if_invalid(url):
raise cmdexc.CommandError(errstr) raise cmdexc.CommandError(errstr)
def filename_from_url(url):
"""Get a suitable filename from an URL.
Args:
url: The URL to parse, as a QUrl.
Return:
The suggested filename as a string, or None.
"""
if not url.isValid():
return None
pathname = posixpath.basename(url.path())
if pathname:
return pathname
elif url.host():
return url.host() + '.html'
else:
return None
class FuzzyUrlError(Exception): class FuzzyUrlError(Exception):
"""Exception raised by fuzzy_url on problems.""" """Exception raised by fuzzy_url on problems."""

View File

@ -200,7 +200,6 @@ class MainWindow(QWidget):
message_bridge = self._get_object('message-bridge') message_bridge = self._get_object('message-bridge')
mode_manager = self._get_object('mode-manager') mode_manager = self._get_object('mode-manager')
prompter = self._get_object('prompter') prompter = self._get_object('prompter')
download_manager = self._get_object('download-manager')
# misc # misc
self._tabbed_browser.close_window.connect(self.close) self._tabbed_browser.close_window.connect(self.close)
@ -262,9 +261,6 @@ class MainWindow(QWidget):
completion_obj.on_clear_completion_selection) completion_obj.on_clear_completion_selection)
cmd.hide_completion.connect(completion_obj.hide) cmd.hide_completion.connect(completion_obj.hide)
# downloads
tabs.start_download.connect(download_manager.fetch)
# quickmark completion # quickmark completion
quickmark_manager = objreg.get('quickmark-manager') quickmark_manager = objreg.get('quickmark-manager')
quickmark_manager.changed.connect(completer.init_quickmark_completions) quickmark_manager.changed.connect(completer.init_quickmark_completions)

View File

@ -76,8 +76,6 @@ class TabbedBrowser(tabwidget.TabWidget):
resized: Emitted when the browser window has resized, so the completion resized: Emitted when the browser window has resized, so the completion
widget can adjust its size to it. widget can adjust its size to it.
arg: The new size. arg: The new size.
start_download: Emitted when any tab wants to start downloading
something.
current_tab_changed: The current tab changed to the emitted WebView. current_tab_changed: The current tab changed to the emitted WebView.
""" """
@ -89,7 +87,6 @@ class TabbedBrowser(tabwidget.TabWidget):
cur_link_hovered = pyqtSignal(str, str, str) cur_link_hovered = pyqtSignal(str, str, str)
cur_scroll_perc_changed = pyqtSignal(int, int) cur_scroll_perc_changed = pyqtSignal(int, int)
cur_load_status_changed = pyqtSignal(str) cur_load_status_changed = pyqtSignal(str)
start_download = pyqtSignal('QNetworkReply*')
close_window = pyqtSignal() close_window = pyqtSignal()
resized = pyqtSignal('QRect') resized = pyqtSignal('QRect')
got_cmd = pyqtSignal(str) got_cmd = pyqtSignal(str)
@ -163,8 +160,6 @@ class TabbedBrowser(tabwidget.TabWidget):
self._filter.create(self.cur_load_status_changed, tab)) self._filter.create(self.cur_load_status_changed, tab))
tab.url_text_changed.connect( tab.url_text_changed.connect(
functools.partial(self.on_url_text_changed, tab)) functools.partial(self.on_url_text_changed, tab))
# downloads
page.start_download.connect(self.start_download)
# misc # misc
tab.titleChanged.connect( tab.titleChanged.connect(
functools.partial(self.on_title_changed, tab)) functools.partial(self.on_title_changed, tab))