diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index b46791980..7c0ba2a27 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -37,6 +37,7 @@ Added - New setting `storage -> prompt-download-directory` to download all downloads without asking. - Rapid hinting is now also possible for downloads. +- Directory browsing via `file://` is now supported. Changed ~~~~~~~ diff --git a/MANIFEST.in b/MANIFEST.in index 62d5e78b6..80f440197 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ global-exclude __pycache__ *.pyc *.pyo recursive-include qutebrowser *.py recursive-include qutebrowser/html *.html +recursive-include qutebrowser/img *.svg recursive-include qutebrowser/test *.py recursive-include qutebrowser/javascript *.js graft icons diff --git a/README.asciidoc b/README.asciidoc index c12f8133f..bac1cbdfa 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -134,8 +134,8 @@ Contributors, sorted by the number of commits in descending order: // QUTE_AUTHORS_START * Florian Bruhin -* Bruno Oliveira * Antoni Boucher +* Bruno Oliveira * Martin Tournoij * Raphael Pierzina * Joel Torstensson diff --git a/qutebrowser/browser/network/filescheme.py b/qutebrowser/browser/network/filescheme.py new file mode 100644 index 000000000..3d92f190c --- /dev/null +++ b/qutebrowser/browser/network/filescheme.py @@ -0,0 +1,128 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2015 Florian Bruhin (The Compiler) +# Copyright 2015 Antoni Boucher (antoyo) +# +# 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 . +# +# pylint complains when using .render() on jinja templates, so we make it shut +# up for this whole module. + +"""Handler functions for file:... pages.""" + +import os + +from PyQt5.QtCore import QUrl + +from qutebrowser.browser.network import schemehandler, networkreply +from qutebrowser.utils import utils, jinja + + +def get_file_list(basedir, all_files, filterfunc): + """Get a list of files filtered by a filter function and sorted by name. + + Args: + basedir: The parent directory of all files. + all_files: The list of files to filter and sort. + filterfunc: The filter function. + + Return: + A list of dicts. Each dict contains the name and absname keys. + """ + items = [] + for filename in all_files: + absname = os.path.join(basedir, filename) + if filterfunc(absname): + items.append({'name': filename, 'absname': absname}) + return sorted(items, key=lambda v: v['name'].lower()) + + +def is_root(directory): + """Check if the directory is the root directory. + + Args: + directory: The directory to check. + + Return: + Whether the directory is a root directory or not. + """ + return os.path.dirname(directory) == directory + + +def dirbrowser_html(path): + """Get the directory browser web page. + + Args: + path: The directory path. + + Return: + The HTML of the web page. + """ + title = "Browse directory: {}".format(path) + template = jinja.env.get_template('dirbrowser.html') + # pylint: disable=no-member + # https://bitbucket.org/logilab/pylint/issue/490/ + + folder_icon = utils.resource_filename('img/folder.svg') + file_icon = utils.resource_filename('img/file.svg') + + folder_url = QUrl.fromLocalFile(folder_icon).toString(QUrl.FullyEncoded) + file_url = QUrl.fromLocalFile(file_icon).toString(QUrl.FullyEncoded) + + if is_root(path): + parent = None + else: + parent = os.path.dirname(path) + + try: + all_files = os.listdir(path) + except OSError as e: + html = jinja.env.get_template('error.html').render( + title="Error while reading directory", + url='file://%s' % path, + error=str(e), + icon='') + return html.encode('UTF-8', errors='xmlcharrefreplace') + + files = get_file_list(path, all_files, os.path.isfile) + directories = get_file_list(path, all_files, os.path.isdir) + html = template.render(title=title, url=path, icon='', + parent=parent, files=files, + directories=directories, folder_url=folder_url, + file_url=file_url) + return html.encode('UTF-8', errors='xmlcharrefreplace') + + +class FileSchemeHandler(schemehandler.SchemeHandler): + + """Scheme handler for file: URLs.""" + + def createRequest(self, _op, request, _outgoing_data): + """Create a new request. + + Args: + request: const QNetworkRequest & req + _op: Operation op + _outgoing_data: QIODevice * outgoingData + + Return: + A QNetworkReply for directories, None for files. + """ + path = request.url().toLocalFile() + if os.path.isdir(path): + data = dirbrowser_html(path) + return networkreply.FixedDataNetworkReply( + request, data, 'text/html', self.parent()) diff --git a/qutebrowser/browser/network/networkmanager.py b/qutebrowser/browser/network/networkmanager.py index 6306d26d6..267d9ff6a 100644 --- a/qutebrowser/browser/network/networkmanager.py +++ b/qutebrowser/browser/network/networkmanager.py @@ -31,6 +31,7 @@ from qutebrowser.utils import (message, log, usertypes, utils, objreg, qtutils, urlutils) from qutebrowser.browser import cookies from qutebrowser.browser.network import qutescheme, networkreply +from qutebrowser.browser.network import filescheme HOSTBLOCK_ERROR_STRING = '%HOSTBLOCK%' @@ -97,6 +98,7 @@ class NetworkManager(QNetworkAccessManager): self._requests = [] self._scheme_handlers = { 'qute': qutescheme.QuteSchemeHandler(win_id), + 'file': filescheme.FileSchemeHandler(win_id), } self._set_cookiejar() self._set_cache() @@ -295,6 +297,25 @@ class NetworkManager(QNetworkAccessManager): download.destroyed.connect(self.on_adopted_download_destroyed) download.do_retry.connect(self.adopt_download) + def set_referer(self, req, current_url): + """Set the referer header.""" + referer_header_conf = config.get('network', 'referer-header') + + try: + if referer_header_conf == 'never': + # Note: using ''.encode('ascii') sends a header with no value, + # instead of no header at all + req.setRawHeader('Referer'.encode('ascii'), QByteArray()) + elif (referer_header_conf == 'same-domain' and + not urlutils.same_domain(req.url(), current_url)): + req.setRawHeader('Referer'.encode('ascii'), QByteArray()) + # If refer_header_conf is set to 'always', we leave the header + # alone as QtWebKit did set it. + except urlutils.InvalidUrlError: + # req.url() or current_url can be invalid - this happens on + # https://www.playstation.com/ for example. + pass + # WORKAROUND for: # http://www.riverbankcomputing.com/pipermail/pyqt/2014-September/034806.html # @@ -318,8 +339,10 @@ class NetworkManager(QNetworkAccessManager): """ scheme = req.url().scheme() if scheme in self._scheme_handlers: - return self._scheme_handlers[scheme].createRequest( + result = self._scheme_handlers[scheme].createRequest( op, req, outgoing_data) + if result is not None: + return result host_blocker = objreg.get('host-blocker') if (op == QNetworkAccessManager.GetOperation and @@ -344,22 +367,8 @@ class NetworkManager(QNetworkAccessManager): webview = objreg.get('webview', scope='tab', window=self._win_id, tab=self._tab_id) current_url = webview.url() - referer_header_conf = config.get('network', 'referer-header') - try: - if referer_header_conf == 'never': - # Note: using ''.encode('ascii') sends a header with no value, - # instead of no header at all - req.setRawHeader('Referer'.encode('ascii'), QByteArray()) - elif (referer_header_conf == 'same-domain' and - not urlutils.same_domain(req.url(), current_url)): - req.setRawHeader('Referer'.encode('ascii'), QByteArray()) - # If refer_header_conf is set to 'always', we leave the header - # alone as QtWebKit did set it. - except urlutils.InvalidUrlError: - # req.url() or current_url can be invalid - this happens on - # https://www.playstation.com/ for example. - pass + self.set_referer(req, current_url) accept_language = config.get('network', 'accept-language') if accept_language is not None: diff --git a/qutebrowser/html/dirbrowser.html b/qutebrowser/html/dirbrowser.html new file mode 100644 index 000000000..2365cfbea --- /dev/null +++ b/qutebrowser/html/dirbrowser.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block style %} +{{ super() }} +#dirbrowserContainer { + background: #fff; + min-width: 35em; + max-width: 35em; + position: absolute; + top: 2em; + left: 1em; + padding: 10px; + border: 2px solid #eee; + -webkit-border-radius: 5px; +} + +#dirbrowserTitleText { + font-size: 118%; + font-weight: bold; +} + +ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +ul > li { + background-repeat: no-repeat; + background-size: 22px; + line-height: 22px; + padding-left: 25px; +} + +ul > li { + background-image: url('{{folder_url}}'); +} + +ul.files > li { + background-image: url('{{file_url}}'); +} +{% endblock %} + +{% block content %} +
+
+

Browse directory: {{url}}

+
+ + {% if parent %} + + {% endif %} + +
    + {% for item in directories %} +
  • {{item.name}}
  • + {% endfor %} +
+ + +
+{% endblock %} diff --git a/qutebrowser/img/file.svg b/qutebrowser/img/file.svg new file mode 100644 index 000000000..532f98bbc --- /dev/null +++ b/qutebrowser/img/file.svg @@ -0,0 +1,548 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Generic Text + + + text + plaintext + regular + document + + + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qutebrowser/img/folder.svg b/qutebrowser/img/folder.svg new file mode 100644 index 000000000..79b25c305 --- /dev/null +++ b/qutebrowser/img/folder.svg @@ -0,0 +1,424 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Folder Icon + + + + Jakub Steiner + + + + http://jimmac.musichall.cz + + + folder + directory + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 373e38cbd..32542f8ef 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -89,6 +89,20 @@ def read_file(filename, binary=False): return data +def resource_filename(filename): + """Get the absolute filename of a file contained with qutebrowser. + + Args: + filename: The filename. + + Return: + The absolute filename. + """ + if hasattr(sys, 'frozen'): + return os.path.join(os.path.dirname(sys.executable), filename) + return pkg_resources.resource_filename(qutebrowser.__name__, filename) + + def actute_warning(): """Display a warning about the dead_actute issue if needed.""" # WORKAROUND (remove this when we bump the requirements to 5.3.0) diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index d2b158bd7..9dd6dc137 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -40,6 +40,7 @@ PERFECT_FILES = [ 'qutebrowser/browser/rfc6266.py', 'qutebrowser/browser/webelem.py', 'qutebrowser/browser/network/schemehandler.py', + 'qutebrowser/browser/network/filescheme.py', 'qutebrowser/misc/readline.py', 'qutebrowser/misc/split.py', diff --git a/scripts/dev/freeze.py b/scripts/dev/freeze.py index db6ab1a93..b6dd8e0fc 100755 --- a/scripts/dev/freeze.py +++ b/scripts/dev/freeze.py @@ -61,6 +61,7 @@ def get_build_exe_options(skip_html=False): include_files = [ ('qutebrowser/javascript', 'javascript'), + ('qutebrowser/img', 'img'), ('qutebrowser/git-commit-id', 'git-commit-id'), ('qutebrowser/utils/testfile', 'utils/testfile'), ] diff --git a/tests/browser/network/test_filescheme.py b/tests/browser/network/test_filescheme.py new file mode 100644 index 000000000..e02402b1e --- /dev/null +++ b/tests/browser/network/test_filescheme.py @@ -0,0 +1,217 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Antoni Boucher (antoyo) +# +# 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 . + +"""Tests for qutebrowser.browser.network.filescheme.""" + +import os +import collections + +import pytest +import bs4 +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkRequest + +from qutebrowser.browser.network import filescheme + + +@pytest.mark.parametrize('create_file, create_dir, filterfunc, expected', [ + (True, False, os.path.isfile, True), + (True, False, os.path.isdir, False), + + (False, True, os.path.isfile, False), + (False, True, os.path.isdir, True), + + (False, False, os.path.isfile, False), + (False, False, os.path.isdir, False), +]) +def test_get_file_list(tmpdir, create_file, create_dir, filterfunc, expected): + """Test get_file_list.""" + path = tmpdir / 'foo' + if create_file or create_dir: + path.ensure(dir=create_dir) + + all_files = os.listdir(str(tmpdir)) + + result = filescheme.get_file_list(str(tmpdir), all_files, filterfunc) + item = {'name': 'foo', 'absname': str(path)} + assert (item in result) == expected + + +class TestIsRoot: + + @pytest.mark.windows + @pytest.mark.parametrize('directory, is_root', [ + ('C:\\foo', False), + ('C:\\', True) + ]) + def test_windows(self, directory, is_root): + assert filescheme.is_root(directory) == is_root + + @pytest.mark.posix + @pytest.mark.parametrize('directory, is_root', [ + ('/foo', False), + ('/', True) + ]) + def test_posix(self, directory, is_root): + assert filescheme.is_root(directory) == is_root + + +class TestDirbrowserHtml: + + Parsed = collections.namedtuple('Parsed', 'parent, folders, files') + Item = collections.namedtuple('Item', 'link, text') + + @pytest.fixture + def parser(self): + """Provide a function to get a parsed dirbrowser document.""" + def parse(path): + html = filescheme.dirbrowser_html(path) + soup = bs4.BeautifulSoup(html, 'html.parser') + print(soup.prettify()) + container = soup('div', id='dirbrowserContainer')[0] + + parent_elem = container('ul', class_='parent') + if len(parent_elem) == 0: + parent = None + else: + parent = parent_elem[0].li.a.string + + folders = [] + files = [] + + for li in container('ul', class_='folders')[0]('li'): + item = self.Item(link=li.a['href'], text=str(li.a.string)) + folders.append(item) + + for li in container('ul', class_='files')[0]('li'): + item = self.Item(link=li.a['href'], text=str(li.a.string)) + files.append(item) + + return self.Parsed(parent=parent, folders=folders, files=files) + + return parse + + def test_basic(self): + html = filescheme.dirbrowser_html(os.getcwd()) + soup = bs4.BeautifulSoup(html, 'html.parser') + print(soup.prettify()) + container = soup.div + assert container['id'] == 'dirbrowserContainer' + title_elem = container('div', id='dirbrowserTitle')[0] + title_text = title_elem('p', id='dirbrowserTitleText')[0].text + assert title_text == 'Browse directory: {}'.format(os.getcwd()) + + def test_icons(self, monkeypatch): + """Make sure icon paths are correct file:// URLs.""" + monkeypatch.setattr( + 'qutebrowser.browser.network.filescheme.utils.resource_filename', + lambda name: '/test path/foo.svg') + + html = filescheme.dirbrowser_html(os.getcwd()) + soup = bs4.BeautifulSoup(html, 'html.parser') + print(soup.prettify()) + + css = soup.html.head.style.string + assert "background-image: url('file:///test%20path/foo.svg');" in css + + def test_empty(self, tmpdir, parser): + parsed = parser(str(tmpdir)) + assert parsed.parent + assert not parsed.folders + assert not parsed.files + + def test_files(self, tmpdir, parser): + foo_file = tmpdir / 'foo' + bar_file = tmpdir / 'bar' + foo_file.ensure() + bar_file.ensure() + + parsed = parser(str(tmpdir)) + assert parsed.parent + assert not parsed.folders + foo_item = self.Item('file://' + str(foo_file), foo_file.relto(tmpdir)) + bar_item = self.Item('file://' + str(bar_file), bar_file.relto(tmpdir)) + assert parsed.files == [bar_item, foo_item] + + def test_dirs(self, tmpdir, parser): + foo_dir = tmpdir / 'foo' + bar_dir = tmpdir / 'bar' + foo_dir.ensure(dir=True) + bar_dir.ensure(dir=True) + + parsed = parser(str(tmpdir)) + assert parsed.parent + assert not parsed.files + foo_item = self.Item('file://' + str(foo_dir), foo_dir.relto(tmpdir)) + bar_item = self.Item('file://' + str(bar_dir), bar_dir.relto(tmpdir)) + assert parsed.folders == [bar_item, foo_item] + + def test_mixed(self, tmpdir, parser): + foo_file = tmpdir / 'foo' + bar_dir = tmpdir / 'bar' + foo_file.ensure() + bar_dir.ensure(dir=True) + + parsed = parser(str(tmpdir)) + foo_item = self.Item('file://' + str(foo_file), foo_file.relto(tmpdir)) + bar_item = self.Item('file://' + str(bar_dir), bar_dir.relto(tmpdir)) + assert parsed.parent + assert parsed.files == [foo_item] + assert parsed.folders == [bar_item] + + def test_root_dir(self, tmpdir, parser): + if os.name == 'nt': + root_dir = 'C:\\' + else: + root_dir = '/' + parsed = parser(root_dir) + assert not parsed.parent + + def test_oserror(self, mocker): + m = mocker.patch('qutebrowser.browser.network.filescheme.os.listdir') + m.side_effect = OSError('Error message') + html = filescheme.dirbrowser_html('') + soup = bs4.BeautifulSoup(html, 'html.parser') + print(soup.prettify()) + error_title = soup('p', id='errorTitleText')[0].string + error_msg = soup('p', id='errorMessageText')[0].string + assert error_title == 'Unable to load page' + assert error_msg == 'Error message' + + +class TestFileSchemeHandler: + + def test_dir(self, tmpdir): + url = QUrl.fromLocalFile(str(tmpdir)) + req = QNetworkRequest(url) + handler = filescheme.FileSchemeHandler(win_id=0) + reply = handler.createRequest(None, req, None) + # The URL will always use /, even on Windows - so we force this here + # too. + tmpdir_path = str(tmpdir).replace(os.sep, '/') + assert reply.readAll() == filescheme.dirbrowser_html(tmpdir_path) + + def test_file(self, tmpdir): + filename = tmpdir / 'foo' + filename.ensure() + url = QUrl.fromLocalFile(str(filename)) + req = QNetworkRequest(url) + handler = filescheme.FileSchemeHandler(win_id=0) + reply = handler.createRequest(None, req, None) + assert reply is None diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 9c0e2e6c4..02bed2f64 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -92,19 +92,21 @@ class TestEliding: assert utils.elide(text, length) == expected +@pytest.fixture(params=[True, False]) +def freezer(request, monkeypatch): + if request.param and not getattr(sys, 'frozen', False): + monkeypatch.setattr(sys, 'frozen', True, raising=False) + monkeypatch.setattr('sys.executable', qutebrowser.__file__) + elif not request.param and getattr(sys, 'frozen', False): + # Want to test unfrozen tests, but we are frozen + pytest.skip("Can't run with sys.frozen = True!") + + +@pytest.mark.usefixtures('freezer') class TestReadFile: """Test read_file.""" - @pytest.fixture(autouse=True, params=[True, False]) - def freezer(self, request, monkeypatch): - if request.param and not getattr(sys, 'frozen', False): - monkeypatch.setattr(sys, 'frozen', True, raising=False) - monkeypatch.setattr('sys.executable', qutebrowser.__file__) - elif not request.param and getattr(sys, 'frozen', False): - # Want to test unfrozen tests, but we are frozen - pytest.skip("Can't run with sys.frozen = True!") - def test_readfile(self): """Read a test file.""" content = utils.read_file(os.path.join('utils', 'testfile')) @@ -117,6 +119,14 @@ class TestReadFile: assert content.splitlines()[0] == b"Hello World!" +@pytest.mark.usefixtures('freezer') +def test_resource_filename(): + """Read a test file.""" + filename = utils.resource_filename(os.path.join('utils', 'testfile')) + with open(filename, 'r', encoding='utf-8') as f: + assert f.read().splitlines()[0] == "Hello World!" + + class Patcher: """Helper for TestActuteWarning. diff --git a/tox.ini b/tox.ini index 679dba9e7..9f2e7092a 100644 --- a/tox.ini +++ b/tox.ini @@ -25,6 +25,7 @@ deps = coverage==3.7.1 pytest-cov==2.0.0 cov-core==1.15.0 + beautifulsoup4==4.4.0 commands = {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} -m py.test --strict -rfEsw --cov qutebrowser --cov-report xml --cov-report= {posargs:tests}