Merge branch 'Kingdread-open-download'
This commit is contained in:
commit
d70f3a0417
@ -32,6 +32,8 @@ Added
|
|||||||
Note that two former default bundings conflict with that binding, unbinding
|
Note that two former default bundings conflict with that binding, unbinding
|
||||||
them via `:unbind .i` and `:unbind .o` is recommended.
|
them via `:unbind .i` and `:unbind .o` is recommended.
|
||||||
- New `qute:bookmarks` page which displays all bookmarks and quickmarks.
|
- 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
|
Changed
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
@ -936,6 +936,7 @@ How many steps to zoom out.
|
|||||||
|<<paste-primary,paste-primary>>|Paste the primary selection at cursor position.
|
|<<paste-primary,paste-primary>>|Paste the primary selection at cursor position.
|
||||||
|<<prompt-accept,prompt-accept>>|Accept the current prompt.
|
|<<prompt-accept,prompt-accept>>|Accept the current prompt.
|
||||||
|<<prompt-no,prompt-no>>|Answer no to a yes/no 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.
|
|<<prompt-yes,prompt-yes>>|Answer yes to a yes/no prompt.
|
||||||
|<<repeat-command,repeat-command>>|Repeat the last executed command.
|
|<<repeat-command,repeat-command>>|Repeat the last executed command.
|
||||||
|<<rl-backward-char,rl-backward-char>>|Move back a character.
|
|<<rl-backward-char,rl-backward-char>>|Move back a character.
|
||||||
@ -1160,6 +1161,10 @@ Accept the current prompt.
|
|||||||
=== prompt-no
|
=== prompt-no
|
||||||
Answer no to a yes/no prompt.
|
Answer no to a yes/no prompt.
|
||||||
|
|
||||||
|
[[prompt-open-download]]
|
||||||
|
=== prompt-open-download
|
||||||
|
Immediately open a download.
|
||||||
|
|
||||||
[[prompt-yes]]
|
[[prompt-yes]]
|
||||||
=== prompt-yes
|
=== prompt-yes
|
||||||
Answer yes to a yes/no prompt.
|
Answer yes to a yes/no prompt.
|
||||||
|
@ -47,7 +47,7 @@ from qutebrowser.completion.models import instances as completionmodels
|
|||||||
from qutebrowser.commands import cmdutils, runners, cmdexc
|
from qutebrowser.commands import cmdutils, runners, cmdexc
|
||||||
from qutebrowser.config import style, config, websettings, configexc
|
from qutebrowser.config import style, config, websettings, configexc
|
||||||
from qutebrowser.browser import urlmarks, adblock
|
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,
|
from qutebrowser.browser.webkit.network import (qutescheme, proxy,
|
||||||
networkmanager)
|
networkmanager)
|
||||||
from qutebrowser.mainwindow import mainwindow
|
from qutebrowser.mainwindow import mainwindow
|
||||||
@ -436,6 +436,8 @@ def _init_modules(args, crash_handler):
|
|||||||
os.environ.pop('QT_WAYLAND_DISABLE_WINDOWDECORATION', None)
|
os.environ.pop('QT_WAYLAND_DISABLE_WINDOWDECORATION', None)
|
||||||
_maybe_hide_mouse_cursor()
|
_maybe_hide_mouse_cursor()
|
||||||
objreg.get('config').changed.connect(_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):
|
def _init_late_modules(args):
|
||||||
@ -708,6 +710,8 @@ class Quitter:
|
|||||||
not restart):
|
not restart):
|
||||||
atexit.register(shutil.rmtree, self._args.basedir,
|
atexit.register(shutil.rmtree, self._args.basedir,
|
||||||
ignore_errors=True)
|
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
|
# If we don't kill our custom handler here we might get segfaults
|
||||||
log.destroy.debug("Deactivating message handler...")
|
log.destroy.debug("Deactivating message handler...")
|
||||||
qInstallMessageHandler(None)
|
qInstallMessageHandler(None)
|
||||||
|
@ -27,7 +27,7 @@ import zipfile
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
|
|
||||||
from qutebrowser.config import config
|
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
|
from qutebrowser.commands import cmdutils, cmdexc
|
||||||
|
|
||||||
|
|
||||||
@ -210,7 +210,8 @@ class HostBlocker:
|
|||||||
else:
|
else:
|
||||||
fobj = io.BytesIO()
|
fobj = io.BytesIO()
|
||||||
fobj.name = 'adblock: ' + url.host()
|
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)
|
auto_remove=True)
|
||||||
self._in_progress.append(download)
|
self._in_progress.append(download)
|
||||||
download.finished.connect(
|
download.finished.connect(
|
||||||
|
@ -1267,15 +1267,23 @@ class CommandDispatcher:
|
|||||||
" as mhtml.")
|
" as mhtml.")
|
||||||
url = urlutils.qurl_from_user_input(url)
|
url = urlutils.qurl_from_user_input(url)
|
||||||
urlutils.raise_cmdexc_if_invalid(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_:
|
elif mhtml_:
|
||||||
self._download_mhtml(dest)
|
self._download_mhtml(dest)
|
||||||
else:
|
else:
|
||||||
# FIXME:qtwebengine have a proper API for this
|
# FIXME:qtwebengine have a proper API for this
|
||||||
tab = self._current_widget()
|
tab = self._current_widget()
|
||||||
page = tab._widget.page() # pylint: disable=protected-access
|
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,
|
download_manager.get(self._current_url(), page=page,
|
||||||
filename=dest)
|
target=target)
|
||||||
|
|
||||||
def _download_mhtml(self, dest=None):
|
def _download_mhtml(self, dest=None):
|
||||||
"""Download the current page as an MHTML file, including all assets.
|
"""Download the current page as an MHTML file, including all assets.
|
||||||
|
@ -25,6 +25,7 @@ import sys
|
|||||||
import os.path
|
import os.path
|
||||||
import shutil
|
import shutil
|
||||||
import functools
|
import functools
|
||||||
|
import tempfile
|
||||||
import collections
|
import collections
|
||||||
|
|
||||||
import sip
|
import sip
|
||||||
@ -280,6 +281,7 @@ class DownloadItem(QObject):
|
|||||||
_read_timer: A Timer which reads the QNetworkReply into self._buffer
|
_read_timer: A Timer which reads the QNetworkReply into self._buffer
|
||||||
periodically.
|
periodically.
|
||||||
_win_id: The window ID the DownloadItem runs in.
|
_win_id: The window ID the DownloadItem runs in.
|
||||||
|
_dead: Whether the Download has _die()'d.
|
||||||
|
|
||||||
Signals:
|
Signals:
|
||||||
data_changed: The downloads metadata changed.
|
data_changed: The downloads metadata changed.
|
||||||
@ -328,6 +330,7 @@ class DownloadItem(QObject):
|
|||||||
self.init_reply(reply)
|
self.init_reply(reply)
|
||||||
self._win_id = win_id
|
self._win_id = win_id
|
||||||
self.raw_headers = {}
|
self.raw_headers = {}
|
||||||
|
self._dead = False
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return utils.get_repr(self, basename=self.basename)
|
return utils.get_repr(self, basename=self.basename)
|
||||||
@ -395,6 +398,21 @@ class DownloadItem(QObject):
|
|||||||
def _die(self, msg):
|
def _die(self, msg):
|
||||||
"""Abort the download and emit an error."""
|
"""Abort the download and emit an error."""
|
||||||
assert not self.successful
|
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._read_timer.stop()
|
||||||
self.reply.downloadProgress.disconnect()
|
self.reply.downloadProgress.disconnect()
|
||||||
self.reply.finished.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
|
# Here no signals are connected to the DownloadItem yet, so we use a
|
||||||
# singleShot QTimer to emit them after they are connected.
|
# singleShot QTimer to emit them after they are connected.
|
||||||
if reply.error() != QNetworkReply.NoError:
|
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):
|
def get_status_color(self, position):
|
||||||
"""Choose an appropriate color for presenting the download's status.
|
"""Choose an appropriate color for presenting the download's status.
|
||||||
@ -513,7 +531,13 @@ class DownloadItem(QObject):
|
|||||||
def open_file(self):
|
def open_file(self):
|
||||||
"""Open the downloaded file."""
|
"""Open the downloaded file."""
|
||||||
assert self.successful
|
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)
|
QDesktopServices.openUrl(url)
|
||||||
|
|
||||||
def set_filename(self, filename):
|
def set_filename(self, filename):
|
||||||
@ -738,6 +762,9 @@ class DownloadManager(QAbstractListModel):
|
|||||||
def _postprocess_question(self, q):
|
def _postprocess_question(self, q):
|
||||||
"""Postprocess a Question object that is asked."""
|
"""Postprocess a Question object that is asked."""
|
||||||
q.destroyed.connect(functools.partial(self.questions.remove, q))
|
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)
|
self.questions.append(q)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
@ -757,10 +784,7 @@ class DownloadManager(QAbstractListModel):
|
|||||||
**kwargs: passed to get_request().
|
**kwargs: passed to get_request().
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
If the download could start immediately, (fileobj/filename given),
|
The created DownloadItem.
|
||||||
the created DownloadItem.
|
|
||||||
|
|
||||||
If not, None.
|
|
||||||
"""
|
"""
|
||||||
if not url.isValid():
|
if not url.isValid():
|
||||||
urlutils.invalid_url_error(self._win_id, url, "start download")
|
urlutils.invalid_url_error(self._win_id, url, "start download")
|
||||||
@ -768,27 +792,17 @@ class DownloadManager(QAbstractListModel):
|
|||||||
req = QNetworkRequest(url)
|
req = QNetworkRequest(url)
|
||||||
return self.get_request(req, **kwargs)
|
return self.get_request(req, **kwargs)
|
||||||
|
|
||||||
def get_request(self, request, *, fileobj=None, filename=None,
|
def get_request(self, request, *, target=None, **kwargs):
|
||||||
prompt_download_directory=None, **kwargs):
|
|
||||||
"""Start a download with a QNetworkRequest.
|
"""Start a download with a QNetworkRequest.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The QNetworkRequest to download.
|
request: The QNetworkRequest to download.
|
||||||
fileobj: The file object to write the answer to.
|
target: Where to save the download as usertypes.DownloadTarget.
|
||||||
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.
|
|
||||||
**kwargs: Passed to fetch_request.
|
**kwargs: Passed to fetch_request.
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
If the download could start immediately, (fileobj/filename given),
|
The created DownloadItem.
|
||||||
the created DownloadItem.
|
|
||||||
|
|
||||||
If not, None.
|
|
||||||
"""
|
"""
|
||||||
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:
|
# WORKAROUND for Qt corrupting data loaded from cache:
|
||||||
# https://bugreports.qt.io/browse/QTBUG-42757
|
# https://bugreports.qt.io/browse/QTBUG-42757
|
||||||
request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
|
request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
|
||||||
@ -816,27 +830,10 @@ class DownloadManager(QAbstractListModel):
|
|||||||
if suggested_fn is None:
|
if suggested_fn is None:
|
||||||
suggested_fn = 'qutebrowser-download'
|
suggested_fn = 'qutebrowser-download'
|
||||||
|
|
||||||
# We won't need a question if a filename or fileobj is already given
|
return self.fetch_request(request,
|
||||||
if fileobj is None and filename is None:
|
target=target,
|
||||||
filename, q = ask_for_filename(
|
suggested_filename=suggested_fn,
|
||||||
suggested_fn, self._win_id, parent=self,
|
**kwargs)
|
||||||
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
|
|
||||||
|
|
||||||
def fetch_request(self, request, *, page=None, **kwargs):
|
def fetch_request(self, request, *, page=None, **kwargs):
|
||||||
"""Download a QNetworkRequest to disk.
|
"""Download a QNetworkRequest to disk.
|
||||||
@ -857,27 +854,25 @@ class DownloadManager(QAbstractListModel):
|
|||||||
return self.fetch(reply, **kwargs)
|
return self.fetch(reply, **kwargs)
|
||||||
|
|
||||||
@pyqtSlot('QNetworkReply')
|
@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):
|
suggested_filename=None, prompt_download_directory=None):
|
||||||
"""Download a QNetworkReply to disk.
|
"""Download a QNetworkReply to disk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
reply: The QNetworkReply to download.
|
reply: The QNetworkReply to download.
|
||||||
fileobj: The file object to write the answer to.
|
target: Where to save the download as usertypes.DownloadTarget.
|
||||||
filename: A path to write the data to.
|
|
||||||
auto_remove: Whether to remove the download even if
|
auto_remove: Whether to remove the download even if
|
||||||
ui -> remove-finished-downloads is set to -1.
|
ui -> remove-finished-downloads is set to -1.
|
||||||
|
|
||||||
Return:
|
Return:
|
||||||
The created DownloadItem.
|
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 not suggested_filename:
|
||||||
if filename is not None:
|
if isinstance(target, usertypes.FileDownloadTarget):
|
||||||
suggested_filename = os.path.basename(filename)
|
suggested_filename = os.path.basename(target.filename)
|
||||||
elif fileobj is not None and getattr(fileobj, 'name', None):
|
elif (isinstance(target, usertypes.FileObjDownloadTarget) and
|
||||||
suggested_filename = fileobj.name
|
getattr(target.fileobj, 'name', None)):
|
||||||
|
suggested_filename = target.fileobj.name
|
||||||
else:
|
else:
|
||||||
_, suggested_filename = http.parse_content_disposition(reply)
|
_, suggested_filename = http.parse_content_disposition(reply)
|
||||||
log.downloads.debug("fetch: {} -> {}".format(reply.url(),
|
log.downloads.debug("fetch: {} -> {}".format(reply.url(),
|
||||||
@ -909,13 +904,8 @@ class DownloadManager(QAbstractListModel):
|
|||||||
if not self._update_timer.isActive():
|
if not self._update_timer.isActive():
|
||||||
self._update_timer.start()
|
self._update_timer.start()
|
||||||
|
|
||||||
if fileobj is not None:
|
if target is not None:
|
||||||
download.set_fileobj(fileobj)
|
self._set_download_target(download, suggested_filename, target)
|
||||||
download.autoclose = False
|
|
||||||
return download
|
|
||||||
|
|
||||||
if filename is not None:
|
|
||||||
download.set_filename(filename)
|
|
||||||
return download
|
return download
|
||||||
|
|
||||||
# Neither filename nor fileobj were given, prepare a question
|
# 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
|
# User doesn't want to be asked, so just use the download_dir
|
||||||
if filename is not None:
|
if filename is not None:
|
||||||
download.set_filename(filename)
|
target = usertypes.FileDownloadTarget(filename)
|
||||||
|
self._set_download_target(download, suggested_filename, target)
|
||||||
return download
|
return download
|
||||||
|
|
||||||
# Ask the user for a filename
|
# Ask the user for a filename
|
||||||
self._postprocess_question(q)
|
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)
|
q.cancelled.connect(download.cancel)
|
||||||
download.cancelled.connect(q.abort)
|
download.cancelled.connect(q.abort)
|
||||||
download.error.connect(q.abort)
|
download.error.connect(q.abort)
|
||||||
@ -939,6 +932,28 @@ class DownloadManager(QAbstractListModel):
|
|||||||
|
|
||||||
return download
|
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):
|
def raise_no_download(self, count):
|
||||||
"""Raise an exception that the download doesn't exist.
|
"""Raise an exception that the download doesn't exist.
|
||||||
|
|
||||||
@ -1249,3 +1264,59 @@ class DownloadManager(QAbstractListModel):
|
|||||||
The number of unfinished downloads.
|
The number of unfinished downloads.
|
||||||
"""
|
"""
|
||||||
return sum(1 for download in self.downloads if not download.done)
|
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
|
||||||
|
@ -343,7 +343,8 @@ class _Downloader:
|
|||||||
|
|
||||||
download_manager = objreg.get('download-manager', scope='window',
|
download_manager = objreg.get('download-manager', scope='window',
|
||||||
window=self._win_id)
|
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)
|
auto_remove=True)
|
||||||
self.pending_downloads.add((url, item))
|
self.pending_downloads.add((url, item))
|
||||||
item.finished.connect(functools.partial(self._finished, url, item))
|
item.finished.connect(functools.partial(self._finished, url, item))
|
||||||
|
@ -1574,6 +1574,7 @@ KEY_DATA = collections.OrderedDict([
|
|||||||
('prompt-accept', RETURN_KEYS),
|
('prompt-accept', RETURN_KEYS),
|
||||||
('prompt-yes', ['y']),
|
('prompt-yes', ['y']),
|
||||||
('prompt-no', ['n']),
|
('prompt-no', ['n']),
|
||||||
|
('prompt-open-download', ['<Ctrl-X>']),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
('command,prompt', collections.OrderedDict([
|
('command,prompt', collections.OrderedDict([
|
||||||
|
@ -80,6 +80,7 @@ class Prompter(QObject):
|
|||||||
usertypes.PromptMode.text: usertypes.KeyMode.prompt,
|
usertypes.PromptMode.text: usertypes.KeyMode.prompt,
|
||||||
usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt,
|
usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt,
|
||||||
usertypes.PromptMode.alert: usertypes.KeyMode.prompt,
|
usertypes.PromptMode.alert: usertypes.KeyMode.prompt,
|
||||||
|
usertypes.PromptMode.download: usertypes.KeyMode.prompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
show_prompt = pyqtSignal()
|
show_prompt = pyqtSignal()
|
||||||
@ -164,12 +165,9 @@ class Prompter(QObject):
|
|||||||
suffix = " (no)"
|
suffix = " (no)"
|
||||||
prompt.txt.setText(self._question.text + suffix)
|
prompt.txt.setText(self._question.text + suffix)
|
||||||
prompt.lineedit.hide()
|
prompt.lineedit.hide()
|
||||||
elif self._question.mode == usertypes.PromptMode.text:
|
elif self._question.mode in [usertypes.PromptMode.text,
|
||||||
prompt.txt.setText(self._question.text)
|
usertypes.PromptMode.user_pwd,
|
||||||
if self._question.default:
|
usertypes.PromptMode.download]:
|
||||||
prompt.lineedit.setText(self._question.default)
|
|
||||||
prompt.lineedit.show()
|
|
||||||
elif self._question.mode == usertypes.PromptMode.user_pwd:
|
|
||||||
prompt.txt.setText(self._question.text)
|
prompt.txt.setText(self._question.text)
|
||||||
if self._question.default:
|
if self._question.default:
|
||||||
prompt.lineedit.setText(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,
|
modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt,
|
||||||
'prompt accept')
|
'prompt accept')
|
||||||
self._question.done()
|
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:
|
elif self._question.mode == usertypes.PromptMode.yesno:
|
||||||
# User wants to accept the default of a yes/no question.
|
# User wants to accept the default of a yes/no question.
|
||||||
self._question.answer = self._question.default
|
self._question.answer = self._question.default
|
||||||
@ -287,6 +292,18 @@ class Prompter(QObject):
|
|||||||
'prompt accept')
|
'prompt accept')
|
||||||
self._question.done()
|
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)
|
@pyqtSlot(usertypes.Question, bool)
|
||||||
def ask_question(self, question, blocking):
|
def ask_question(self, question, blocking):
|
||||||
"""Display a question in the statusbar.
|
"""Display a question in the statusbar.
|
||||||
|
@ -221,7 +221,8 @@ class NeighborList(collections.abc.Sequence):
|
|||||||
|
|
||||||
|
|
||||||
# The mode of a Question.
|
# 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.
|
# Where to open a clicked link.
|
||||||
@ -255,6 +256,50 @@ LoadStatus = enum('LoadStatus', ['none', 'success', 'success_https', 'error',
|
|||||||
Backend = enum('Backend', ['QtWebKit', 'QtWebEngine'])
|
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):
|
class Question(QObject):
|
||||||
|
|
||||||
"""A question asked to the user, e.g. via the status bar.
|
"""A question asked to the user, e.g. via the status bar.
|
||||||
|
@ -81,14 +81,14 @@ def check_spelling():
|
|||||||
"""Check commonly misspelled words."""
|
"""Check commonly misspelled words."""
|
||||||
# Words which I often misspell
|
# Words which I often misspell
|
||||||
words = {'[Bb]ehaviour', '[Qq]uitted', 'Ll]ikelyhood', '[Ss]ucessfully',
|
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',
|
'[Aa]uxillary', '[Aa]ccidentaly', '[Aa]mbigious', '[Ll]oosly',
|
||||||
'[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited',
|
'[Ii]nitialis', '[Cc]onvienence', '[Ss]imiliar', '[Uu]ncommited',
|
||||||
'[Rr]eproducable', '[Aa]n [Uu]ser', '[Cc]onvienience',
|
'[Rr]eproducable', '[Aa]n [Uu]ser', '[Cc]onvienience',
|
||||||
'[Ww]ether', '[Pp]rogramatically', '[Ss]plitted', '[Ee]xitted',
|
'[Ww]ether', '[Pp]rogramatically', '[Ss]plitted', '[Ee]xitted',
|
||||||
'[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily',
|
'[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily',
|
||||||
'[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting',
|
'[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting',
|
||||||
'existant'}
|
'existant', '[Rr]esetted'}
|
||||||
|
|
||||||
# Words which look better when splitted, but might need some fine tuning.
|
# Words which look better when splitted, but might need some fine tuning.
|
||||||
words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence',
|
words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence',
|
||||||
|
@ -30,9 +30,8 @@ Feature: Downloading things from a website.
|
|||||||
And I open data/downloads/issue1243.html
|
And I open data/downloads/issue1243.html
|
||||||
And I run :hint links download
|
And I run :hint links download
|
||||||
And I run :follow-hint a
|
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 wait for "Asking question <qutebrowser.utils.usertypes.Question default='qutebrowser-download' mode=<PromptMode.download: 5> text='Save file to:'>, *" in the log
|
||||||
And I run :leave-mode
|
Then the error "Download error: No handler found for qute://!" should be shown
|
||||||
Then no crash should happen
|
|
||||||
|
|
||||||
Scenario: Downloading a data: link (issue 1214)
|
Scenario: Downloading a data: link (issue 1214)
|
||||||
When I set completion -> download-path-suggestion to filename
|
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 open data/downloads/issue1214.html
|
||||||
And I run :hint links download
|
And I run :hint links download
|
||||||
And I run :follow-hint a
|
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
|
And I run :leave-mode
|
||||||
Then no crash should happen
|
Then no crash should happen
|
||||||
|
|
||||||
|
@ -312,7 +312,7 @@ Feature: Various utility commands.
|
|||||||
And I open data/misc/test.pdf
|
And I open data/misc/test.pdf
|
||||||
And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log
|
And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log
|
||||||
And I run :jseval document.getElementById("download").click()
|
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
|
And I run :leave-mode
|
||||||
Then no crash should happen
|
Then no crash should happen
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ def download_should_exist(filename, tmpdir):
|
|||||||
def download_prompt(tmpdir, quteproc, path):
|
def download_prompt(tmpdir, quteproc, path):
|
||||||
full_path = path.replace('{downloaddir}', str(tmpdir)).replace('/', os.sep)
|
full_path = path.replace('{downloaddir}', str(tmpdir)).replace('/', os.sep)
|
||||||
msg = ("Asking question <qutebrowser.utils.usertypes.Question "
|
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))
|
"text='Save file to:'>, *".format(full_path=full_path))
|
||||||
quteproc.wait_for(message=msg)
|
quteproc.wait_for(message=msg)
|
||||||
quteproc.send_cmd(':leave-mode')
|
quteproc.send_cmd(':leave-mode')
|
||||||
|
@ -85,12 +85,12 @@ class FakeDownloadManager:
|
|||||||
|
|
||||||
"""Mock browser.downloads.DownloadManager."""
|
"""Mock browser.downloads.DownloadManager."""
|
||||||
|
|
||||||
def get(self, url, fileobj, **kwargs):
|
def get(self, url, target, **kwargs):
|
||||||
"""Return a FakeDownloadItem instance with a fileobj.
|
"""Return a FakeDownloadItem instance with a fileobj.
|
||||||
|
|
||||||
The content is copied from the file the given url links to.
|
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:
|
with open(url.path(), 'rb') as fake_url_file:
|
||||||
shutil.copyfileobj(fake_url_file, download_item.fileobj)
|
shutil.copyfileobj(fake_url_file, download_item.fileobj)
|
||||||
return download_item
|
return download_item
|
||||||
|
54
tests/unit/utils/usertypes/test_downloadtarget.py
Normal file
54
tests/unit/utils/usertypes/test_downloadtarget.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user