Merge branch 'Kingdread-open-download'

This commit is contained in:
Florian Bruhin 2016-07-26 10:54:59 +02:00
commit d70f3a0417
16 changed files with 289 additions and 81 deletions

View File

@ -32,6 +32,8 @@ Added
Note that two former default bundings conflict with that binding, unbinding
them via `:unbind .i` and `:unbind .o` is recommended.
- New `qute:bookmarks` page which displays all bookmarks and quickmarks.
- New `:prompt-open-download` (bound to `Ctrl-X`) which can be used to open a
download directly when getting the filename prompt.
Changed
~~~~~~~

View File

@ -936,6 +936,7 @@ How many steps to zoom out.
|<<paste-primary,paste-primary>>|Paste the primary selection at cursor position.
|<<prompt-accept,prompt-accept>>|Accept the current prompt.
|<<prompt-no,prompt-no>>|Answer no to a yes/no prompt.
|<<prompt-open-download,prompt-open-download>>|Immediately open a download.
|<<prompt-yes,prompt-yes>>|Answer yes to a yes/no prompt.
|<<repeat-command,repeat-command>>|Repeat the last executed command.
|<<rl-backward-char,rl-backward-char>>|Move back a character.
@ -1160,6 +1161,10 @@ Accept the current prompt.
=== prompt-no
Answer no to a yes/no prompt.
[[prompt-open-download]]
=== prompt-open-download
Immediately open a download.
[[prompt-yes]]
=== prompt-yes
Answer yes to a yes/no prompt.

View File

@ -47,7 +47,7 @@ from qutebrowser.completion.models import instances as completionmodels
from qutebrowser.commands import cmdutils, runners, cmdexc
from qutebrowser.config import style, config, websettings, configexc
from qutebrowser.browser import urlmarks, adblock
from qutebrowser.browser.webkit import cookies, cache, history
from qutebrowser.browser.webkit import cookies, cache, history, downloads
from qutebrowser.browser.webkit.network import (qutescheme, proxy,
networkmanager)
from qutebrowser.mainwindow import mainwindow
@ -436,6 +436,8 @@ def _init_modules(args, crash_handler):
os.environ.pop('QT_WAYLAND_DISABLE_WINDOWDECORATION', None)
_maybe_hide_mouse_cursor()
objreg.get('config').changed.connect(_maybe_hide_mouse_cursor)
temp_downloads = downloads.TempDownloadManager(qApp)
objreg.register('temporary-downloads', temp_downloads)
def _init_late_modules(args):
@ -708,6 +710,8 @@ class Quitter:
not restart):
atexit.register(shutil.rmtree, self._args.basedir,
ignore_errors=True)
# Delete temp download dir
objreg.get('temporary-downloads').cleanup()
# If we don't kill our custom handler here we might get segfaults
log.destroy.debug("Deactivating message handler...")
qInstallMessageHandler(None)

View File

@ -27,7 +27,7 @@ import zipfile
import fnmatch
from qutebrowser.config import config
from qutebrowser.utils import objreg, standarddir, log, message
from qutebrowser.utils import objreg, standarddir, log, message, usertypes
from qutebrowser.commands import cmdutils, cmdexc
@ -210,7 +210,8 @@ class HostBlocker:
else:
fobj = io.BytesIO()
fobj.name = 'adblock: ' + url.host()
download = download_manager.get(url, fileobj=fobj,
target = usertypes.FileObjDownloadTarget(fobj)
download = download_manager.get(url, target=target,
auto_remove=True)
self._in_progress.append(download)
download.finished.connect(

View File

@ -1267,15 +1267,23 @@ class CommandDispatcher:
" as mhtml.")
url = urlutils.qurl_from_user_input(url)
urlutils.raise_cmdexc_if_invalid(url)
download_manager.get(url, filename=dest)
if dest is None:
target = None
else:
target = usertypes.FileDownloadTarget(dest)
download_manager.get(url, target=target)
elif mhtml_:
self._download_mhtml(dest)
else:
# FIXME:qtwebengine have a proper API for this
tab = self._current_widget()
page = tab._widget.page() # pylint: disable=protected-access
if dest is None:
target = None
else:
target = usertypes.FileDownloadTarget(dest)
download_manager.get(self._current_url(), page=page,
filename=dest)
target=target)
def _download_mhtml(self, dest=None):
"""Download the current page as an MHTML file, including all assets.

View File

@ -25,6 +25,7 @@ import sys
import os.path
import shutil
import functools
import tempfile
import collections
import sip
@ -280,6 +281,7 @@ class DownloadItem(QObject):
_read_timer: A Timer which reads the QNetworkReply into self._buffer
periodically.
_win_id: The window ID the DownloadItem runs in.
_dead: Whether the Download has _die()'d.
Signals:
data_changed: The downloads metadata changed.
@ -328,6 +330,7 @@ class DownloadItem(QObject):
self.init_reply(reply)
self._win_id = win_id
self.raw_headers = {}
self._dead = False
def __repr__(self):
return utils.get_repr(self, basename=self.basename)
@ -395,6 +398,21 @@ class DownloadItem(QObject):
def _die(self, msg):
"""Abort the download and emit an error."""
assert not self.successful
# Prevent actions if calling _die() twice. This might happen if the
# error handler correctly connects, and the error occurs in init_reply
# between reply.error.connect and the reply.error() check. In this
# case, the connected error handlers will be called twice, once via the
# direct error.emit() and once here in _die(). The stacks look like
# this then:
# <networkmanager error.emit> -> on_reply_error -> _die ->
# self.error.emit()
# and
# [init_reply -> <single shot timer> ->] <lambda in init_reply> ->
# self.error.emit()
# which may lead to duplicate error messages (and failing tests)
if self._dead:
return
self._dead = True
self._read_timer.stop()
self.reply.downloadProgress.disconnect()
self.reply.finished.disconnect()
@ -441,7 +459,7 @@ class DownloadItem(QObject):
# Here no signals are connected to the DownloadItem yet, so we use a
# singleShot QTimer to emit them after they are connected.
if reply.error() != QNetworkReply.NoError:
QTimer.singleShot(0, lambda: self.error.emit(reply.errorString()))
QTimer.singleShot(0, lambda: self._die(reply.errorString()))
def get_status_color(self, position):
"""Choose an appropriate color for presenting the download's status.
@ -513,7 +531,13 @@ class DownloadItem(QObject):
def open_file(self):
"""Open the downloaded file."""
assert self.successful
url = QUrl.fromLocalFile(self._filename)
filename = self._filename
if filename is None:
filename = getattr(self.fileobj, 'name', None)
if filename is None:
log.downloads.error("No filename to open the download!")
return
url = QUrl.fromLocalFile(filename)
QDesktopServices.openUrl(url)
def set_filename(self, filename):
@ -738,6 +762,9 @@ class DownloadManager(QAbstractListModel):
def _postprocess_question(self, q):
"""Postprocess a Question object that is asked."""
q.destroyed.connect(functools.partial(self.questions.remove, q))
# We set the mode here so that other code that uses ask_for_filename
# doesn't need to handle the special download mode.
q.mode = usertypes.PromptMode.download
self.questions.append(q)
@pyqtSlot()
@ -757,10 +784,7 @@ class DownloadManager(QAbstractListModel):
**kwargs: passed to get_request().
Return:
If the download could start immediately, (fileobj/filename given),
the created DownloadItem.
If not, None.
The created DownloadItem.
"""
if not url.isValid():
urlutils.invalid_url_error(self._win_id, url, "start download")
@ -768,27 +792,17 @@ class DownloadManager(QAbstractListModel):
req = QNetworkRequest(url)
return self.get_request(req, **kwargs)
def get_request(self, request, *, fileobj=None, filename=None,
prompt_download_directory=None, **kwargs):
def get_request(self, request, *, target=None, **kwargs):
"""Start a download with a QNetworkRequest.
Args:
request: The QNetworkRequest to download.
fileobj: The file object to write the answer to.
filename: A path to write the data to.
prompt_download_directory: Whether to prompt for the download dir
or automatically download. If None, the
config is used.
target: Where to save the download as usertypes.DownloadTarget.
**kwargs: Passed to fetch_request.
Return:
If the download could start immediately, (fileobj/filename given),
the created DownloadItem.
If not, None.
The created DownloadItem.
"""
if fileobj is not None and filename is not None: # pragma: no cover
raise TypeError("Only one of fileobj/filename may be given!")
# WORKAROUND for Qt corrupting data loaded from cache:
# https://bugreports.qt.io/browse/QTBUG-42757
request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
@ -816,27 +830,10 @@ class DownloadManager(QAbstractListModel):
if suggested_fn is None:
suggested_fn = 'qutebrowser-download'
# We won't need a question if a filename or fileobj is already given
if fileobj is None and filename is None:
filename, q = ask_for_filename(
suggested_fn, self._win_id, parent=self,
prompt_download_directory=prompt_download_directory
)
if fileobj is not None or filename is not None:
return self.fetch_request(request,
fileobj=fileobj,
filename=filename,
suggested_filename=suggested_fn,
**kwargs)
q.answered.connect(
lambda fn: self.fetch_request(request,
filename=fn,
suggested_filename=suggested_fn,
**kwargs))
self._postprocess_question(q)
q.ask()
return None
return self.fetch_request(request,
target=target,
suggested_filename=suggested_fn,
**kwargs)
def fetch_request(self, request, *, page=None, **kwargs):
"""Download a QNetworkRequest to disk.
@ -857,27 +854,25 @@ class DownloadManager(QAbstractListModel):
return self.fetch(reply, **kwargs)
@pyqtSlot('QNetworkReply')
def fetch(self, reply, *, fileobj=None, filename=None, auto_remove=False,
def fetch(self, reply, *, target=None, auto_remove=False,
suggested_filename=None, prompt_download_directory=None):
"""Download a QNetworkReply to disk.
Args:
reply: The QNetworkReply to download.
fileobj: The file object to write the answer to.
filename: A path to write the data to.
target: Where to save the download as usertypes.DownloadTarget.
auto_remove: Whether to remove the download even if
ui -> remove-finished-downloads is set to -1.
Return:
The created DownloadItem.
"""
if fileobj is not None and filename is not None: # pragma: no cover
raise TypeError("Only one of fileobj/filename may be given!")
if not suggested_filename:
if filename is not None:
suggested_filename = os.path.basename(filename)
elif fileobj is not None and getattr(fileobj, 'name', None):
suggested_filename = fileobj.name
if isinstance(target, usertypes.FileDownloadTarget):
suggested_filename = os.path.basename(target.filename)
elif (isinstance(target, usertypes.FileObjDownloadTarget) and
getattr(target.fileobj, 'name', None)):
suggested_filename = target.fileobj.name
else:
_, suggested_filename = http.parse_content_disposition(reply)
log.downloads.debug("fetch: {} -> {}".format(reply.url(),
@ -909,13 +904,8 @@ class DownloadManager(QAbstractListModel):
if not self._update_timer.isActive():
self._update_timer.start()
if fileobj is not None:
download.set_fileobj(fileobj)
download.autoclose = False
return download
if filename is not None:
download.set_filename(filename)
if target is not None:
self._set_download_target(download, suggested_filename, target)
return download
# Neither filename nor fileobj were given, prepare a question
@ -926,12 +916,15 @@ class DownloadManager(QAbstractListModel):
# User doesn't want to be asked, so just use the download_dir
if filename is not None:
download.set_filename(filename)
target = usertypes.FileDownloadTarget(filename)
self._set_download_target(download, suggested_filename, target)
return download
# Ask the user for a filename
self._postprocess_question(q)
q.answered.connect(download.set_filename)
q.answered.connect(
functools.partial(self._set_download_target, download,
suggested_filename))
q.cancelled.connect(download.cancel)
download.cancelled.connect(q.abort)
download.error.connect(q.abort)
@ -939,6 +932,28 @@ class DownloadManager(QAbstractListModel):
return download
def _set_download_target(self, download, suggested_filename, target):
"""Set the target for a given download.
Args:
download: The download to set the filename for.
suggested_filename: The suggested filename.
target: The usertypes.DownloadTarget for this download.
"""
if isinstance(target, usertypes.FileObjDownloadTarget):
download.set_fileobj(target.fileobj)
download.autoclose = False
elif isinstance(target, usertypes.FileDownloadTarget):
download.set_filename(target.filename)
elif isinstance(target, usertypes.OpenFileDownloadTarget):
tmp_manager = objreg.get('temporary-downloads')
fobj = tmp_manager.get_tmpfile(suggested_filename)
download.finished.connect(download.open_file)
download.autoclose = True
download.set_fileobj(fobj)
else:
log.downloads.error("Unknown download target: {}".format(target))
def raise_no_download(self, count):
"""Raise an exception that the download doesn't exist.
@ -1249,3 +1264,59 @@ class DownloadManager(QAbstractListModel):
The number of unfinished downloads.
"""
return sum(1 for download in self.downloads if not download.done)
class TempDownloadManager(QObject):
"""Manager to handle temporary download files.
The downloads are downloaded to a temporary location and then openened with
the system standard application. The temporary files are deleted when
qutebrowser is shutdown.
Attributes:
files: A list of NamedTemporaryFiles of downloaded items.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.files = []
self._tmpdir = None
def cleanup(self):
"""Clean up any temporary files."""
if self._tmpdir is not None:
self._tmpdir.cleanup()
self._tmpdir = None
def _get_tmpdir(self):
"""Return the temporary directory that is used for downloads.
The directory is created lazily on first access.
Return:
The tempfile.TemporaryDirectory that is used.
"""
if self._tmpdir is None:
self._tmpdir = tempfile.TemporaryDirectory(
prefix='qutebrowser-downloads-')
return self._tmpdir
def get_tmpfile(self, suggested_name):
"""Return a temporary file in the temporary downloads directory.
The files are kept as long as qutebrowser is running and automatically
cleaned up at program exit.
Args:
suggested_name: str of the "suggested"/original filename. Used as a
suffix, so any file extenions are preserved.
Return:
A tempfile.NamedTemporaryFile that should be used to save the file.
"""
tmpdir = self._get_tmpdir()
fobj = tempfile.NamedTemporaryFile(dir=tmpdir.name, delete=False,
suffix=suggested_name)
self.files.append(fobj)
return fobj

View File

@ -343,7 +343,8 @@ class _Downloader:
download_manager = objreg.get('download-manager', scope='window',
window=self._win_id)
item = download_manager.get(url, fileobj=_NoCloseBytesIO(),
target = usertypes.FileObjDownloadTarget(_NoCloseBytesIO())
item = download_manager.get(url, target=target,
auto_remove=True)
self.pending_downloads.add((url, item))
item.finished.connect(functools.partial(self._finished, url, item))

View File

@ -1574,6 +1574,7 @@ KEY_DATA = collections.OrderedDict([
('prompt-accept', RETURN_KEYS),
('prompt-yes', ['y']),
('prompt-no', ['n']),
('prompt-open-download', ['<Ctrl-X>']),
])),
('command,prompt', collections.OrderedDict([

View File

@ -80,6 +80,7 @@ class Prompter(QObject):
usertypes.PromptMode.text: usertypes.KeyMode.prompt,
usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt,
usertypes.PromptMode.alert: usertypes.KeyMode.prompt,
usertypes.PromptMode.download: usertypes.KeyMode.prompt,
}
show_prompt = pyqtSignal()
@ -164,12 +165,9 @@ class Prompter(QObject):
suffix = " (no)"
prompt.txt.setText(self._question.text + suffix)
prompt.lineedit.hide()
elif self._question.mode == usertypes.PromptMode.text:
prompt.txt.setText(self._question.text)
if self._question.default:
prompt.lineedit.setText(self._question.default)
prompt.lineedit.show()
elif self._question.mode == usertypes.PromptMode.user_pwd:
elif self._question.mode in [usertypes.PromptMode.text,
usertypes.PromptMode.user_pwd,
usertypes.PromptMode.download]:
prompt.txt.setText(self._question.text)
if self._question.default:
prompt.lineedit.setText(self._question.default)
@ -248,6 +246,13 @@ class Prompter(QObject):
modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt,
'prompt accept')
self._question.done()
elif self._question.mode == usertypes.PromptMode.download:
# User just entered a path for a download.
target = usertypes.FileDownloadTarget(prompt.lineedit.text())
self._question.answer = target
modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt,
'prompt accept')
self._question.done()
elif self._question.mode == usertypes.PromptMode.yesno:
# User wants to accept the default of a yes/no question.
self._question.answer = self._question.default
@ -287,6 +292,18 @@ class Prompter(QObject):
'prompt accept')
self._question.done()
@cmdutils.register(instance='prompter', hide=True, scope='window',
modes=[usertypes.KeyMode.prompt])
def prompt_open_download(self):
"""Immediately open a download."""
if self._question.mode != usertypes.PromptMode.download:
# We just ignore this if we don't have a download question.
return
self._question.answer = usertypes.OpenFileDownloadTarget()
modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt,
'download open')
self._question.done()
@pyqtSlot(usertypes.Question, bool)
def ask_question(self, question, blocking):
"""Display a question in the statusbar.

View File

@ -221,7 +221,8 @@ class NeighborList(collections.abc.Sequence):
# The mode of a Question.
PromptMode = enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert'])
PromptMode = enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert',
'download'])
# Where to open a clicked link.
@ -255,6 +256,50 @@ LoadStatus = enum('LoadStatus', ['none', 'success', 'success_https', 'error',
Backend = enum('Backend', ['QtWebKit', 'QtWebEngine'])
# Where a download should be saved
class DownloadTarget:
"""Abstract base class for different download targets."""
def __init__(self):
raise NotImplementedError
class FileDownloadTarget(DownloadTarget):
"""Save the download to the given file.
Attributes:
filename: Filename where the download should be saved.
"""
def __init__(self, filename):
# pylint: disable=super-init-not-called
self.filename = filename
class FileObjDownloadTarget(DownloadTarget):
"""Save the download to the given file-like object.
Attributes:
fileobj: File-like object where the download should be written to.
"""
def __init__(self, fileobj):
# pylint: disable=super-init-not-called
self.fileobj = fileobj
class OpenFileDownloadTarget(DownloadTarget):
"""Save the download in a temp dir and directly open it."""
def __init__(self):
# pylint: disable=super-init-not-called
pass
class Question(QObject):
"""A question asked to the user, e.g. via the status bar.

View File

@ -81,14 +81,14 @@ def check_spelling():
"""Check commonly misspelled words."""
# Words which I often misspell
words = {'[Bb]ehaviour', '[Qq]uitted', 'Ll]ikelyhood', '[Ss]ucessfully',
'[Oo]ccur[^r .]', '[Ss]eperator', '[Ee]xplicitely', '[Rr]esetted',
'[Oo]ccur[^rs .]', '[Ss]eperator', '[Ee]xplicitely',
'[Aa]uxillary', '[Aa]ccidentaly', '[Aa]mbigious', '[Ll]oosly',
'[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited',
'[Rr]eproducable', '[Aa]n [Uu]ser', '[Cc]onvienience',
'[Ww]ether', '[Pp]rogramatically', '[Ss]plitted', '[Ee]xitted',
'[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily',
'[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting',
'existant'}
'existant', '[Rr]esetted'}
# Words which look better when splitted, but might need some fine tuning.
words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence',

View File

@ -30,9 +30,8 @@ Feature: Downloading things from a website.
And I open data/downloads/issue1243.html
And I run :hint links download
And I run :follow-hint a
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=<PromptMode.text: 2> text='Save file to:'>, *" in the log
And I run :leave-mode
Then no crash should happen
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=<PromptMode.download: 5> text='Save file to:'>, *" in the log
Then the error "Download error: No handler found for qute://!" should be shown
Scenario: Downloading a data: link (issue 1214)
When I set completion -> download-path-suggestion to filename
@ -40,7 +39,7 @@ Feature: Downloading things from a website.
And I open data/downloads/issue1214.html
And I run :hint links download
And I run :follow-hint a
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='binary blob' mode=<PromptMode.text: 2> text='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='binary blob' mode=<PromptMode.download: 5> text='Save file to:'>, *" in the log
And I run :leave-mode
Then no crash should happen

View File

@ -312,7 +312,7 @@ Feature: Various utility commands.
And I open data/misc/test.pdf
And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log
And I run :jseval document.getElementById("download").click()
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='test.pdf' mode=<PromptMode.text: 2> text='Save file to:'>, *" in the log
And I wait for "Asking question <qutebrowser.utils.usertypes.Question default='test.pdf' mode=<PromptMode.download: 5> text='Save file to:'>, *" in the log
And I run :leave-mode
Then no crash should happen

View File

@ -64,7 +64,7 @@ def download_should_exist(filename, tmpdir):
def download_prompt(tmpdir, quteproc, path):
full_path = path.replace('{downloaddir}', str(tmpdir)).replace('/', os.sep)
msg = ("Asking question <qutebrowser.utils.usertypes.Question "
"default={full_path!r} mode=<PromptMode.text: 2> "
"default={full_path!r} mode=<PromptMode.download: 5> "
"text='Save file to:'>, *".format(full_path=full_path))
quteproc.wait_for(message=msg)
quteproc.send_cmd(':leave-mode')

View File

@ -85,12 +85,12 @@ class FakeDownloadManager:
"""Mock browser.downloads.DownloadManager."""
def get(self, url, fileobj, **kwargs):
def get(self, url, target, **kwargs):
"""Return a FakeDownloadItem instance with a fileobj.
The content is copied from the file the given url links to.
"""
download_item = FakeDownloadItem(fileobj, name=url.path())
download_item = FakeDownloadItem(target.fileobj, name=url.path())
with open(url.path(), 'rb') as fake_url_file:
shutil.copyfileobj(fake_url_file, download_item.fileobj)
return download_item

View File

@ -0,0 +1,54 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016 Daniel Schadt
#
# 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 <http://www.gnu.org/licenses/>.
"""Tests for the DownloadTarget class."""
from qutebrowser.utils import usertypes
import pytest
def test_base():
with pytest.raises(NotImplementedError):
usertypes.DownloadTarget()
def test_filename():
target = usertypes.FileDownloadTarget("/foo/bar")
assert target.filename == "/foo/bar"
def test_fileobj():
fobj = object()
target = usertypes.FileObjDownloadTarget(fobj)
assert target.fileobj is fobj
def test_openfile():
# Just make sure no error is raised, that should be enough.
usertypes.OpenFileDownloadTarget()
@pytest.mark.parametrize('obj', [
usertypes.FileDownloadTarget('foobar'),
usertypes.FileObjDownloadTarget(None),
usertypes.OpenFileDownloadTarget(),
])
def test_class_hierarchy(obj):
assert isinstance(obj, usertypes.DownloadTarget)