diff --git a/qutebrowser/browser/webkit/downloads.py b/qutebrowser/browser/webkit/downloads.py index 9a1a49db8..86670986b 100644 --- a/qutebrowser/browser/webkit/downloads.py +++ b/qutebrowser/browser/webkit/downloads.py @@ -25,6 +25,7 @@ import sys import os.path import shutil import functools +import tempfile import collections import sip @@ -513,7 +514,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): @@ -731,6 +738,20 @@ class DownloadManager(QAbstractListModel): self._update_timer = usertypes.Timer(self, 'download-update') self._update_timer.timeout.connect(self.update_gui) self._update_timer.setInterval(REFRESH_INTERVAL) + self._tmpdir_obj = None + + def cleanup(self): + """Clean up any temporary files from this manager.""" + if self._tmpdir_obj is not None: + self._tmpdir_obj.cleanup() + + @property + def tmpdir(self): + """Lazily create a temporary directory if one is needed.""" + if self._tmpdir_obj is None: + self._tmpdir_obj = tempfile.TemporaryDirectory( + prefix='qutebrowser-downloads-') + return self._tmpdir_obj def __repr__(self): return utils.get_repr(self, downloads=len(self.downloads)) @@ -738,6 +759,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() @@ -816,27 +840,11 @@ 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, + fileobj=fileobj, + filename=filename, + suggested_filename=suggested_fn, + **kwargs) def fetch_request(self, request, *, page=None, **kwargs): """Download a QNetworkRequest to disk. @@ -874,7 +882,8 @@ class DownloadManager(QAbstractListModel): 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: + if (filename is not None and + filename is not usertypes.OPEN_DOWNLOAD): suggested_filename = os.path.basename(filename) elif fileobj is not None and getattr(fileobj, 'name', None): suggested_filename = fileobj.name @@ -915,7 +924,8 @@ class DownloadManager(QAbstractListModel): return download if filename is not None: - download.set_filename(filename) + self.set_filename_for_download(download, suggested_filename, + filename) return download # Neither filename nor fileobj were given, prepare a question @@ -926,12 +936,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) + self.set_filename_for_download(download, suggested_filename, + filename) 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_filename_for_download, download, + suggested_filename)) q.cancelled.connect(download.cancel) download.cancelled.connect(q.abort) download.error.connect(q.abort) @@ -939,6 +952,35 @@ class DownloadManager(QAbstractListModel): return download + def set_filename_for_download(self, download, suggested_filename, + filename): + """Set the filename for a given download. + + This correctly handles the case where filename = OPEN_DOWNLOAD. + + Args: + download: The download to set the filename for. + suggested_filename: The suggested filename. + filename: The filename as string or usertypes.OPEN_DOWNLOAD. + """ + if filename is not usertypes.OPEN_DOWNLOAD: + download.set_filename(filename) + return + # Find the next free filename without causing a race condition + index = 0 + while True: + basename = '{}-{}'.format(index, suggested_filename) + path = os.path.join(self.tmpdir.name, basename) + try: + fobj = open(path, 'xb') + except FileExistsError: + index += 1 + else: + break + download.finished.connect(download.open_file) + download.autoclose = True + download.set_fileobj(fobj) + def raise_no_download(self, count): """Raise an exception that the download doesn't exist. diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 54a735609..77b24e116 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -452,6 +452,7 @@ class MainWindow(QWidget): self._save_geometry() log.destroy.debug("Closing window {}".format(self.win_id)) self.tabbed_browser.shutdown() + self._get_object('download-manager').cleanup() def closeEvent(self, e): """Override closeEvent to display a confirmation if needed.""" diff --git a/qutebrowser/mainwindow/statusbar/prompter.py b/qutebrowser/mainwindow/statusbar/prompter.py index dbf1084b0..2700a2220 100644 --- a/qutebrowser/mainwindow/statusbar/prompter.py +++ b/qutebrowser/mainwindow/statusbar/prompter.py @@ -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,12 @@ 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. + self._question.answer = prompt.lineedit.text() + 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 +291,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.OPEN_DOWNLOAD + 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. diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 872531d4f..b21676fde 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -33,6 +33,7 @@ from qutebrowser.utils import log, qtutils, utils _UNSET = object() +OPEN_DOWNLOAD = object() def enum(name, items, start=1, is_int=False): @@ -221,7 +222,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.