diff options
author | Lembrun <amadeusk7@free.fr> | 2021-03-07 18:00:36 +0100 |
---|---|---|
committer | Lembrun <amadeusk7@free.fr> | 2021-03-07 18:00:36 +0100 |
commit | ff83241c81a6574106c70642b7de4824ccc1e9be (patch) | |
tree | 22cf073a1a60d3d9f15c3a6c68203e43d1317867 | |
parent | a015e2603b91cdc796c3729e612112dacc0a62fc (diff) | |
parent | 423e7e25bdd7de78c7a75d70bfc92171fcf8ccaf (diff) | |
download | qutebrowser-ff83241c81a6574106c70642b7de4824ccc1e9be.tar.gz qutebrowser-ff83241c81a6574106c70642b7de4824ccc1e9be.zip |
Merge branch 'master' into Add-utils/resources.py
25 files changed, 549 insertions, 110 deletions
diff --git a/README.asciidoc b/README.asciidoc index 704058bd7..43e6a19e4 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -92,7 +92,6 @@ websites and using it for transmission of sensitive data._ * https://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.12.0 or newer for Python 3 * https://palletsprojects.com/p/jinja/[jinja2] -* https://pygments.org/[pygments] * https://github.com/yaml/pyyaml[PyYAML] On older Python versions (3.6/3.7/3.8), the following backports are also required: @@ -103,6 +102,9 @@ On older Python versions (3.6/3.7/3.8), the following backports are also require The following libraries are optional: * https://pypi.org/project/adblock/[adblock] (for improved adblocking using ABP syntax) +* https://pygments.org/[pygments] for syntax highlighting with `:view-source` + on QtWebKit, or when using `:view-source --pygments` with the (default) + QtWebEngine backend. * On Windows, https://pypi.python.org/pypi/colorama/[colorama] for colored log output. * https://importlib-metadata.readthedocs.io/[importlib_resources] on Python 3.7 diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index c5636204f..3840f369d 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -27,6 +27,7 @@ Added - New optional dependency on the `importlib_metadata` project on Python 3.7 and below. This is only relevant when PyQtWebEngine is installed via pip - thus, this dependency usually isn't relevant for packagers. +- New `qute-keepassxc` userscript integrating with the KeePassXC browser API. Changed ~~~~~~~ @@ -54,6 +55,11 @@ Changed long line. - If a command stats with space (e.g. `: open ...`, it's now not saved to command history anymore (similar to how some shells work). +- When a tab is pinned, running `:open` will now open a new tab instead of + displaying an error. +- The `fileselect.*.command` settings now support file selectors writing the + selected paths to stdout, which is used if no `{}` placeholder is contained in + the configured command. Fixed ~~~~~ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 7d0b3469c..392f60c49 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -207,8 +207,8 @@ |<<editor.command,editor.command>>|Editor (and arguments) to use for the `edit-*` commands. |<<editor.encoding,editor.encoding>>|Encoding to use for the editor. |<<fileselect.handler,fileselect.handler>>|Handler for selecting file(s) in forms. If `external`, then the commands specified by `fileselect.single_file.command` and `fileselect.multiple_files.command` are used to select one or multiple files respectively. -|<<fileselect.multiple_files.command,fileselect.multiple_files.command>>|Command (and arguments) to use for selecting multiple files in forms. The command should write the selected file paths to the specified file, separated by newlines. -|<<fileselect.single_file.command,fileselect.single_file.command>>|Command (and arguments) to use for selecting a single file in forms. The command should write the selected file path to the specified file. +|<<fileselect.multiple_files.command,fileselect.multiple_files.command>>|Command (and arguments) to use for selecting multiple files in forms. The command should write the selected file paths to the specified file or to stdout, separated by newlines. +|<<fileselect.single_file.command,fileselect.single_file.command>>|Command (and arguments) to use for selecting a single file in forms. The command should write the selected file path to the specified file or stdout. |<<fonts.completion.category,fonts.completion.category>>|Font used in the completion categories. |<<fonts.completion.entry,fonts.completion.entry>>|Font used in the completion widget. |<<fonts.contextmenu,fonts.contextmenu>>|Font used for the context menu. @@ -2825,9 +2825,10 @@ Default: +pass:[default]+ [[fileselect.multiple_files.command]] === fileselect.multiple_files.command -Command (and arguments) to use for selecting multiple files in forms. The command should write the selected file paths to the specified file, separated by newlines. +Command (and arguments) to use for selecting multiple files in forms. The command should write the selected file paths to the specified file or to stdout, separated by newlines. The following placeholders are defined: -* `{}`: Filename of the file to be written to. +* `{}`: Filename of the file to be written to. If not contained in any argument, the + standard output of the command is read instead. Type: <<types,ShellCommand>> @@ -2840,9 +2841,10 @@ Default: [[fileselect.single_file.command]] === fileselect.single_file.command -Command (and arguments) to use for selecting a single file in forms. The command should write the selected file path to the specified file. +Command (and arguments) to use for selecting a single file in forms. The command should write the selected file path to the specified file or stdout. The following placeholders are defined: -* `{}`: Filename of the file to be written to. +* `{}`: Filename of the file to be written to. If not contained in any argument, the + standard output of the command is read instead. Type: <<types,ShellCommand>> diff --git a/doc/img/cheatsheet-big.png b/doc/img/cheatsheet-big.png Binary files differindex ecd52c14e..75e2abb89 100644 --- a/doc/img/cheatsheet-big.png +++ b/doc/img/cheatsheet-big.png diff --git a/doc/img/cheatsheet-small.png b/doc/img/cheatsheet-small.png Binary files differindex 0dc01e8b4..e97d63367 100644 --- a/doc/img/cheatsheet-small.png +++ b/doc/img/cheatsheet-small.png diff --git a/misc/cheatsheet.svg b/misc/cheatsheet.svg index 7e8a7b381..e908f9496 100644 --- a/misc/cheatsheet.svg +++ b/misc/cheatsheet.svg @@ -11,7 +11,7 @@ height="682.66669" id="svg2" sodipodi:version="0.32" - inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07, custom)" + inkscape:version="1.0.2 (e86c870879, 2021-01-15)" version="1.0" sodipodi:docname="cheatsheet.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape" @@ -30,16 +30,16 @@ objecttolerance="10" inkscape:pageopacity="0.0" inkscape:pageshadow="2" - inkscape:zoom="1.7536248" - inkscape:cx="466.08451" - inkscape:cy="268.64059" + inkscape:zoom="2.48" + inkscape:cx="834.18001" + inkscape:cy="692.30401" inkscape:document-units="px" inkscape:current-layer="layer1" width="1024px" height="640px" showgrid="false" - inkscape:window-width="3822" - inkscape:window-height="2128" + inkscape:window-width="1914" + inkscape:window-height="1048" inkscape:window-x="0" inkscape:window-y="16" showguides="true" @@ -3113,8 +3113,6 @@ style="font-size:10.6667px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06667" id="flowPara3925">ss - set setting (sl: temp)</flowPara><flowPara style="font-size:10.6667px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06667" - id="flowPara3927" /><flowPara - style="font-size:10.6667px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06667" id="flowPara3929">sk - bind key</flowPara><flowPara style="font-size:10.6667px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06667" id="flowPara3931">Ss - show settings</flowPara><flowPara diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index c93b5896d..bf214be0d 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -8,7 +8,7 @@ chardet==4.0.0 cheroot==8.5.2 click==7.1.2 # colorama==0.4.4 -coverage==5.5 ; python_version!="3.10" +coverage==5.5 EasyProcess==0.3 execnet==1.8.0 filelock==3.0.12 @@ -59,4 +59,3 @@ toml==0.10.2 urllib3==1.26.3 vulture==2.3 Werkzeug==1.0.1 -coverage==5.4; python_version=="3.10" diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw index 196a80753..ab580ac4b 100644 --- a/misc/requirements/requirements-tests.txt-raw +++ b/misc/requirements/requirements-tests.txt-raw @@ -34,7 +34,3 @@ pytest-icdiff tldextract #@ ignore: Jinja2, MarkupSafe, colorama - -# WORKAROUND for https://github.com/nedbat/coveragepy/issues/1129 -#@ markers: coverage python_version!="3.10" -#@ add: coverage==5.4; python_version=="3.10" diff --git a/misc/userscripts/README.md b/misc/userscripts/README.md index 938dd776d..395797805 100644 --- a/misc/userscripts/README.md +++ b/misc/userscripts/README.md @@ -17,6 +17,8 @@ The following userscripts are included in the current directory. current website. - [qute-keepass](./qute-keepass): Insertion of usernames and passwords from keepass databases using pykeepass. +- [qute-keepassxc](./qute-keepassxc): Insert credentials from open KeepassXC database + using keepassxc-browser protocol. - [qute-pass](./qute-pass): Insert login information using pass and a dmenu-compatible application (e.g. dmenu, rofi -dmenu, ...). - [qute-lastpass](./qute-lastpass): Similar to qute-pass, for Lastpass. diff --git a/misc/userscripts/qute-keepassxc b/misc/userscripts/qute-keepassxc new file mode 100755 index 000000000..f0127590b --- /dev/null +++ b/misc/userscripts/qute-keepassxc @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2018-2021 Markus Blöchl <ususdei@gmail.com> +# +# 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 <https://www.gnu.org/licenses/>. + +""" +# Introduction + +This is a [qutebrowser][2] [userscript][5] to fill website credentials from a [KeepassXC][1] password database. + + +# Installation + +First, you need to enable [KeepassXC-Browser][6] extensions in your KeepassXC config. + + +Second, you must make sure to have a working private-public-key-pair in your [GPG keyring][3]. + + +Third, install the python module `pynacl`. + + +Finally, adapt your qutebrowser config. +You can e.g. add the following lines to your `~/.config/qutebrowser/config.py` +Remember to replace `ABC1234` with your actual GPG key. + +```python +config.bind('<Alt-Shift-u>', 'spawn --userscript qute-keepassxc --key ABC1234', mode='insert') +config.bind('pw', 'spawn --userscript qute-keepassxc --key ABC1234', mode='normal') +``` + + +# Usage + +If you are on a webpage with a login form, simply activate one of the configured key-bindings. + +The first time you run this script, KeepassXC will ask you for authentication like with any other browser extension. +Just provide a name of your choice and accept the request if nothing looks fishy. + + +# How it works + +This script will talk to KeepassXC using the native [KeepassXC-Browser protocol][4]. + + +This script needs to store the key used to associate with your KeepassXC instance somewhere. +Unlike most browser extensions which only use plain local storage, this one attempts to do so in a safe way +by storing the key in encrypted form using GPG. +Therefore you need to have a public-key-pair readily set up. + +GPG might then ask for your private-key passwort whenever you query the database for login credentials. + + +[1]: https://keepassxc.org/ +[2]: https://qutebrowser.org/ +[3]: https://gnupg.org/ +[4]: https://github.com/keepassxreboot/keepassxc-browser/blob/develop/keepassxc-protocol.md +[5]: https://github.com/qutebrowser/qutebrowser/blob/master/doc/userscripts.asciidoc +[6]: https://keepassxc.org/docs/keepassxc-browser-migration/ +""" + +import sys +import os +import socket +import json +import base64 +import subprocess +import argparse + +import nacl.utils +import nacl.public + + +def parse_args(): + parser = argparse.ArgumentParser(description="Full passwords from KeepassXC") + parser.add_argument('url', nargs='?', default=os.environ.get('QUTE_URL')) + parser.add_argument('--socket', '-s', default='/run/user/{}/org.keepassxc.KeePassXC.BrowserServer'.format(os.getuid()), + help='Path to KeepassXC browser socket') + parser.add_argument('--key', '-k', default='alice@example.com', + help='GPG key to encrypt KeepassXC auth key with') + parser.add_argument('--insecure', action='store_true', + help="Do not encrypt auth key") + return parser.parse_args() + + +class KeepassError(Exception): + def __init__(self, code, desc): + self.code = code + self.description = desc + + def __str__(self): + return f"KeepassXC Error [{self.code}]: {self.description}" + + +class KeepassXC: + """ Wrapper around the KeepassXC socket API """ + def __init__(self, id=None, *, key, socket_path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.id = id + self.socket_path = socket_path + self.client_key = nacl.public.PrivateKey.generate() + self.id_key = nacl.public.PrivateKey.from_seed(key) + self.cryptobox = None + + def connect(self): + if not os.path.exists(self.socket_path): + raise KeepassError(-1, "KeepassXC Browser socket does not exists") + self.client_id = base64.b64encode(nacl.utils.random(nacl.public.Box.NONCE_SIZE)).decode('utf-8') + self.sock.connect(self.socket_path) + + self.send_raw_msg(dict( + action = 'change-public-keys', + publicKey = base64.b64encode(self.client_key.public_key.encode()).decode('utf-8'), + nonce = base64.b64encode(nacl.utils.random(nacl.public.Box.NONCE_SIZE)).decode('utf-8'), + clientID = self.client_id + )) + + resp = self.recv_raw_msg() + assert resp['action'] == 'change-public-keys' + assert resp['success'] == 'true' + assert resp['nonce'] + self.cryptobox = nacl.public.Box( + self.client_key, + nacl.public.PublicKey(base64.b64decode(resp['publicKey'])) + ) + + def get_databasehash(self): + self.send_msg(dict(action='get-databasehash')) + return self.recv_msg()['hash'] + + def lock_database(self): + self.send_msg(dict(action='lock-database')) + try: + self.recv_msg() + except KeepassError as e: + if e.code == 1: + return True + raise + return False + + + def test_associate(self): + if not self.id: + return False + self.send_msg(dict( + action = 'test-associate', + id = self.id, + key = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') + )) + return self.recv_msg()['success'] == 'true' + + def associate(self): + self.send_msg(dict( + action = 'associate', + key = base64.b64encode(self.client_key.public_key.encode()).decode('utf-8'), + idKey = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') + )) + resp = self.recv_msg() + self.id = resp['id'] + + def get_logins(self, url): + self.send_msg(dict( + action = 'get-logins', + url = url, + keys = [{ 'id': self.id, 'key': base64.b64encode(self.id_key.public_key.encode()).decode('utf-8') }] + )) + return self.recv_msg()['entries'] + + def send_raw_msg(self, msg): + self.sock.send( json.dumps(msg).encode('utf-8') ) + + def recv_raw_msg(self): + return json.loads( self.sock.recv(4096).decode('utf-8') ) + + def send_msg(self, msg, **extra): + nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE) + self.send_raw_msg(dict( + action = msg['action'], + message = base64.b64encode(self.cryptobox.encrypt(json.dumps(msg).encode('utf-8'), nonce).ciphertext).decode('utf-8'), + nonce = base64.b64encode(nonce).decode('utf-8'), + clientID = self.client_id, + **extra + )) + + def recv_msg(self): + resp = self.recv_raw_msg() + if 'error' in resp: + raise KeepassError(resp['errorCode'], resp['error']) + assert resp['action'] + return json.loads(self.cryptobox.decrypt(base64.b64decode(resp['message']), base64.b64decode(resp['nonce'])).decode('utf-8')) + + + +class SecretKeyStore: + def __init__(self, gpgkey): + self.gpgkey = gpgkey + if gpgkey is None: + self.path = os.path.join(os.environ['QUTE_DATA_DIR'], 'keepassxc.key') + else: + self.path = os.path.join(os.environ['QUTE_DATA_DIR'], 'keepassxc.key.gpg') + + def load(self): + "Load existing association key from file" + if self.gpgkey is None: + jsondata = open(self.path, 'r').read() + else: + jsondata = subprocess.check_output(['gpg', '--decrypt', self.path]).decode('utf-8') + data = json.loads(jsondata) + self.id = data['id'] + self.key = base64.b64decode(data['key']) + + def create(self): + "Create new association key" + self.key = nacl.utils.random(32) + self.id = None + + def store(self, id): + "Store newly created association key in file" + self.id = id + jsondata = json.dumps({'id':self.id, 'key':base64.b64encode(self.key).decode('utf-8')}) + if self.gpgkey is None: + open(self.path, "w").write(jsondata) + else: + subprocess.run(['gpg', '--encrypt', '-o', self.path, '-r', self.gpgkey], input=jsondata.encode('utf-8'), check=True) + + +def qute(cmd): + with open(os.environ['QUTE_FIFO'], 'w') as fifo: + fifo.write(cmd) + fifo.write('\n') + fifo.flush() + +def error(msg): + print(msg, file=sys.stderr) + qute('message-error "{}"'.format(msg)) + + +def connect_to_keepassxc(args): + assert args.key or args.insecure, "Missing GPG key to use for auth key encryption" + keystore = SecretKeyStore(args.key) + if os.path.isfile(keystore.path): + keystore.load() + kp = KeepassXC(keystore.id, key=keystore.key, socket_path=args.socket) + kp.connect() + if not kp.test_associate(): + error('No KeepassXC association') + return None + else: + keystore.create() + kp = KeepassXC(key=keystore.key, socket_path=args.socket) + kp.connect() + kp.associate() + if not kp.test_associate(): + error('No KeepassXC association') + return None + keystore.store(kp.id) + return kp + + +def make_js_code(username, password): + return ' '.join(""" + function isVisible(elem) { + var style = elem.ownerDocument.defaultView.getComputedStyle(elem, null); + + if (style.getPropertyValue("visibility") !== "visible" || + style.getPropertyValue("display") === "none" || + style.getPropertyValue("opacity") === "0") { + return false; + } + + return elem.offsetWidth > 0 && elem.offsetHeight > 0; + }; + + function hasPasswordField(form) { + var inputs = form.getElementsByTagName("input"); + for (var j = 0; j < inputs.length; j++) { + var input = inputs[j]; + if (input.type === "password") { + return true; + } + } + return false; + }; + + function loadData2Form (form) { + var inputs = form.getElementsByTagName("input"); + for (var j = 0; j < inputs.length; j++) { + var input = inputs[j]; + if (isVisible(input) && (input.type === "text" || input.type === "email")) { + input.focus(); + input.value = %s; + input.dispatchEvent(new Event('input', { 'bubbles': true })); + input.dispatchEvent(new Event('change', { 'bubbles': true })); + input.blur(); + } + if (input.type === "password") { + input.focus(); + input.value = %s; + input.dispatchEvent(new Event('input', { 'bubbles': true })); + input.dispatchEvent(new Event('change', { 'bubbles': true })); + input.blur(); + } + } + }; + + function fillFirstForm() { + var forms = document.getElementsByTagName("form"); + for (i = 0; i < forms.length; i++) { + if (hasPasswordField(forms[i])) { + loadData2Form(forms[i]); + return; + } + } + alert("No Credentials Form found"); + }; + + fillFirstForm() + """.splitlines()) % (json.dumps(username), json.dumps(password)) + + +def main(): + if 'QUTE_FIFO' not in os.environ: + print(f"No QUTE_FIFO found - {sys.argv[0]} must be run as a qutebrowser userscript") + sys.exit(-1) + + try: + args = parse_args() + assert args.url, "Missing URL" + kp = connect_to_keepassxc(args) + if not kp: + error('Could not connect to KeepassXC') + return + creds = kp.get_logins(args.url) + if not creds: + error('No credentials found') + return + # TODO: handle multiple matches + name, pw = creds[0]['login'], creds[0]['password'] + if name and pw: + qute('jseval -q ' + make_js_code(name, pw)) + except Exception as e: + error(str(e)) + + +if __name__ == '__main__': + main() + diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 4c26da69d..581d33507 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -870,7 +870,7 @@ class AbstractTabPrivate: tabdata = self._tab.data if tabdata.inspector is None: assert tabdata.splitter is not None - tabdata.inspector = inspector.create( + tabdata.inspector = self._init_inspector( splitter=tabdata.splitter, win_id=self._tab.win_id) self._tab.shutting_down.connect(tabdata.inspector.shutdown) @@ -878,6 +878,18 @@ class AbstractTabPrivate: tabdata.inspector.inspect(self._widget.page()) tabdata.inspector.set_position(position) + def _init_inspector(self, splitter: 'miscwidgets.InspectorSplitter', + win_id: int, + parent: QWidget = None) -> 'AbstractWebInspector': + """Get a WebKitInspector/WebEngineInspector. + + Args: + splitter: InspectorSplitter where the inspector can be placed. + win_id: The window ID this inspector is associated with. + parent: The Qt parent to set. + """ + raise NotImplementedError + class AbstractTab(QWidget): diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index dc0664238..f2dd282df 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -329,7 +329,9 @@ class CommandDispatcher: # Explicit count with a tab that doesn't exist. return elif curtab.navigation_blocked(): - message.info("Tab is pinned!") + message.info("Tab is pinned! Opening in new tab.") + self._tabbed_browser.tabopen(cur_url) + else: curtab.load_url(cur_url) diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py index 9ed5b52ea..2b40e97e4 100644 --- a/qutebrowser/browser/inspector.py +++ b/qutebrowser/browser/inspector.py @@ -30,30 +30,9 @@ from PyQt5.QtGui import QCloseEvent from qutebrowser.browser import eventfilter from qutebrowser.config import configfiles -from qutebrowser.utils import log, usertypes, utils +from qutebrowser.utils import log, usertypes from qutebrowser.keyinput import modeman -from qutebrowser.misc import miscwidgets, objects - - -def create(*, splitter: 'miscwidgets.InspectorSplitter', - win_id: int, - parent: QWidget = None) -> 'AbstractWebInspector': - """Get a WebKitInspector/WebEngineInspector. - - Args: - splitter: InspectorSplitter where the inspector can be placed. - win_id: The window ID this inspector is associated with. - parent: The Qt parent to set. - """ - # Importing modules here so we don't depend on QtWebEngine without the - # argument and to avoid circular imports. - if objects.backend == usertypes.Backend.QtWebEngine: - from qutebrowser.browser.webengine import webengineinspector - return webengineinspector.WebEngineInspector(splitter, win_id, parent) - elif objects.backend == usertypes.Backend.QtWebKit: - from qutebrowser.browser.webkit import webkitinspector - return webkitinspector.WebKitInspector(splitter, win_id, parent) - raise utils.Unreachable(objects.backend) +from qutebrowser.misc import miscwidgets class Position(enum.Enum): diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py index 78b475835..94332ffcb 100644 --- a/qutebrowser/browser/shared.py +++ b/qutebrowser/browser/shared.py @@ -23,7 +23,7 @@ import os import sys import html import netrc -from typing import Callable, Mapping, List +from typing import Callable, Mapping, List, Optional import tempfile from PyQt5.QtCore import QUrl @@ -360,23 +360,60 @@ def choose_file(multiple: bool) -> List[str]: A list of selected file paths, or empty list if no file is selected. If multiple is False, the return value will have at most 1 item. """ - handle = tempfile.NamedTemporaryFile(prefix='qutebrowser-fileselect-', delete=False) - handle.close() - tmpfilename = handle.name - with utils.cleanup_file(tmpfilename): - if multiple: - command = config.val.fileselect.multiple_files.command - else: - command = config.val.fileselect.single_file.command + if multiple: + command = config.val.fileselect.multiple_files.command + else: + command = config.val.fileselect.single_file.command + use_tmp_file = any('{}' in arg for arg in command[1:]) + if use_tmp_file: + handle = tempfile.NamedTemporaryFile( + prefix='qutebrowser-fileselect-', + delete=False, + ) + handle.close() + tmpfilename = handle.name + with utils.cleanup_file(tmpfilename): + command = ( + command[:1] + + [arg.replace('{}', tmpfilename) for arg in command[1:]] + ) + return _execute_fileselect_command( + command=command, + multiple=multiple, + tmpfilename=tmpfilename, + ) + else: + return _execute_fileselect_command( + command=command, + multiple=multiple, + ) - proc = guiprocess.GUIProcess(what='choose-file') - proc.start(command[0], - [arg.replace('{}', tmpfilename) for arg in command[1:]]) - loop = qtutils.EventLoop() - proc.finished.connect(lambda _code, _status: loop.exit()) - loop.exec() +def _execute_fileselect_command( + command: List[str], + multiple: bool, + tmpfilename: Optional[str] = None +) -> List[str]: + """Execute external command to choose file. + Args: + multiple: Should selecting multiple files be allowed. + tmpfilename: Path to the temporary file if used, otherwise None. + + Return: + A list of selected file paths, or empty list if no file is selected. + If multiple is False, the return value will have at most 1 item. + """ + proc = guiprocess.GUIProcess(what='choose-file') + proc.start(command[0], command[1:]) + + loop = qtutils.EventLoop() + proc.finished.connect(lambda _code, _status: loop.exit()) + loop.exec() + + if tmpfilename is None: + selected_files = proc.final_stdout.splitlines() + else: try: with open(tmpfilename, mode='r', encoding=sys.getfilesystemencoding()) as f: selected_files = f.read().splitlines() diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index a2f6682de..4a80dd623 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -34,11 +34,13 @@ from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript, QWebEngin from qutebrowser.config import config from qutebrowser.browser import browsertab, eventfilter, shared, webelem, greasemonkey from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, - webenginesettings, certificateerror) -from qutebrowser.misc import miscwidgets, objects + webenginesettings, certificateerror, + webengineinspector) + from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, resources, message, jinja, debug, version) from qutebrowser.qt import sip +from qutebrowser.misc import objects, miscwidgets # Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs. @@ -1207,6 +1209,9 @@ class WebEngineTabPrivate(browsertab.AbstractTabPrivate): def run_js_sync(self, code): raise browsertab.UnsupportedOperationError + def _init_inspector(self, splitter, win_id, parent=None): + return webengineinspector.WebEngineInspector(splitter, win_id, parent) + class WebEngineTab(browsertab.AbstractTab): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 9f15e9fb4..a1d3e5574 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -808,6 +808,9 @@ class WebKitTabPrivate(browsertab.AbstractTabPrivate): result = document_element.evaluateJavaScript(code) return result + def _init_inspector(self, splitter, win_id, parent=None): + return webkitinspector.WebKitInspector(splitter, win_id, parent) + class WebKitTab(browsertab.AbstractTab): diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index b4805665b..34d8bec96 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -1259,7 +1259,7 @@ fileselect.handler: fileselect.single_file.command: type: name: ShellCommand - placeholder: true + placeholder: false completions: - ['["xterm", "-e", "ranger", "--choosefile={}"]', "Ranger in xterm"] - ['["xterm", "-e", "vifm", "--choose-files", "{}"]', "vifm in xterm"] @@ -1267,16 +1267,17 @@ fileselect.single_file.command: default: ['xterm', '-e', 'ranger', '--choosefile={}'] desc: >- Command (and arguments) to use for selecting a single file in forms. - The command should write the selected file path to the specified file. + The command should write the selected file path to the specified file or stdout. The following placeholders are defined: - * `{}`: Filename of the file to be written to. + * `{}`: Filename of the file to be written to. If not contained in any argument, the + standard output of the command is read instead. fileselect.multiple_files.command: type: name: ShellCommand - placeholder: true + placeholder: false completions: - ['["xterm", "-e", "ranger", "--choosefiles={}"]', "Ranger in xterm"] - ['["xterm", "-e", "vifm", "--choose-files", "{}"]', "vifm in xterm"] @@ -1284,12 +1285,13 @@ fileselect.multiple_files.command: default: ['xterm', '-e', 'ranger', '--choosefiles={}'] desc: >- Command (and arguments) to use for selecting multiple files in forms. - The command should write the selected file paths to the specified file, + The command should write the selected file paths to the specified file or to stdout, separated by newlines. The following placeholders are defined: - * `{}`: Filename of the file to be written to. + * `{}`: Filename of the file to be written to. If not contained in any argument, the + standard output of the command is read instead. ## hints diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py index 1273b227e..79c84c346 100644 --- a/qutebrowser/misc/guiprocess.py +++ b/qutebrowser/misc/guiprocess.py @@ -61,6 +61,9 @@ class GUIProcess(QObject): self.cmd = None self.args = None + self.final_stdout: str = "" + self.final_stderr: str = "" + self._proc = QProcess(self) self._proc.errorOccurred.connect(self._on_error) self._proc.errorOccurred.connect(self.error) @@ -125,6 +128,8 @@ class GUIProcess(QObject): log.procs.error("Process stderr:\n" + stderr.strip()) qutescheme.spawn_output = self._spawn_format(exitinfo, stdout, stderr) + self.final_stdout = stdout + self.final_stderr = stderr def _spawn_format(self, exitinfo, stdout, stderr): """Produce a formatted string for spawn output.""" diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 8895da55f..612b88637 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -61,6 +61,7 @@ def whitelist_generator(): # noqa: C901 yield 'scripts.utils.bg_colors' yield 'qutebrowser.misc.sql.SqliteErrorCode.CONSTRAINT' yield 'qutebrowser.misc.throttle.Throttle.set_delay' + yield 'qutebrowser.misc.guiprocess.GUIProcess.final_stderr' # Qt attributes yield 'PyQt5.QtWebKit.QWebPage.ErrorPageExtensionReturn().baseUrl' diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature index 52756422c..47cb1230a 100644 --- a/tests/end2end/features/editor.feature +++ b/tests/end2end/features/editor.feature @@ -6,14 +6,14 @@ Feature: Opening external editors Scenario: Editing a URL When I open data/numbers/1.txt - And I set up a fake editor replacing "1.txt" by "2.txt" + And I setup a fake editor replacing "1.txt" by "2.txt" And I run :edit-url Then data/numbers/2.txt should be loaded Scenario: Editing a URL with -t When I run :tab-only And I open data/numbers/1.txt - And I set up a fake editor replacing "1.txt" by "2.txt" + And I setup a fake editor replacing "1.txt" by "2.txt" And I run :edit-url -t Then data/numbers/2.txt should be loaded And the following tabs should be open: @@ -24,7 +24,7 @@ Feature: Opening external editors When I set tabs.new_position.related to prev And I open data/numbers/1.txt And I run :tab-only - And I set up a fake editor replacing "1.txt" by "2.txt" + And I setup a fake editor replacing "1.txt" by "2.txt" And I run :edit-url -rt Then data/numbers/2.txt should be loaded And the following tabs should be open: @@ -34,7 +34,7 @@ Feature: Opening external editors Scenario: Editing a URL with -b When I run :tab-only And I open data/numbers/1.txt - And I set up a fake editor replacing "1.txt" by "2.txt" + And I setup a fake editor replacing "1.txt" by "2.txt" And I run :edit-url -b Then data/numbers/2.txt should be loaded And the following tabs should be open: @@ -45,7 +45,7 @@ Feature: Opening external editors When I run :window-only And I open data/numbers/1.txt in a new tab And I run :tab-only - And I set up a fake editor replacing "1.txt" by "2.txt" + And I setup a fake editor replacing "1.txt" by "2.txt" And I run :edit-url -w Then data/numbers/2.txt should be loaded And the session should look like: @@ -65,7 +65,7 @@ Feature: Opening external editors When I open data/numbers/1.txt in a new tab And I run :tab-only And I run :window-only - And I set up a fake editor replacing "1.txt" by "2.txt" + And I setup a fake editor replacing "1.txt" by "2.txt" And I run :edit-url -p Then data/numbers/2.txt should be loaded And the session should look like: @@ -90,13 +90,13 @@ Feature: Opening external editors Scenario: Editing a URL with invalid URL When I set url.auto_search to never And I open data/hello.txt - And I set up a fake editor replacing "http://localhost:(port)/data/hello.txt" by "foo!" + And I setup a fake editor replacing "http://localhost:(port)/data/hello.txt" by "foo!" And I run :edit-url Then the error "Invalid URL" should be shown Scenario: Spawning an editor successfully Given I have a fresh instance - When I set up a fake editor returning "foobar" + When I setup a fake editor returning "foobar" And I open data/editor.html And I run :click-element id qute-textarea And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log @@ -105,7 +105,7 @@ Feature: Opening external editors Then the javascript message "text: foobar" should be logged Scenario: Spawning an editor in normal mode - When I set up a fake editor returning "foobar" + When I setup a fake editor returning "foobar" And I open data/editor.html And I run :click-element id qute-textarea And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log @@ -119,7 +119,7 @@ Feature: Opening external editors # There's no guarantee that the tab gets deleted... @posix Scenario: Spawning an editor and closing the tab - When I set up a fake editor that writes "foobar" on save + When I setup a fake editor that writes "foobar" on save And I open data/editor.html And I run :click-element id qute-textarea And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log @@ -134,7 +134,7 @@ Feature: Opening external editors # Could not get signals working on Windows @posix Scenario: Spawning an editor and saving - When I set up a fake editor that writes "foobar" on save + When I setup a fake editor that writes "foobar" on save And I open data/editor.html And I run :click-element id qute-textarea And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log @@ -145,7 +145,7 @@ Feature: Opening external editors Then the javascript message "text: foobar" should be logged Scenario: Spawning an editor in caret mode - When I set up a fake editor returning "foobar" + When I setup a fake editor returning "foobar" And I open data/editor.html And I run :click-element id qute-textarea And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log @@ -159,7 +159,7 @@ Feature: Opening external editors Then the javascript message "text: foobar" should be logged Scenario: Spawning an editor with existing text - When I set up a fake editor replacing "foo" by "bar" + When I setup a fake editor replacing "foo" by "bar" And I open data/editor.html And I run :click-element id qute-textarea And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log @@ -173,20 +173,20 @@ Feature: Opening external editors Scenario: Edit a command and run it When I run :set-cmd-text :message-info foo - And I set up a fake editor replacing "foo" by "bar" + And I setup a fake editor replacing "foo" by "bar" And I run :edit-command --run Then the message "bar" should be shown And "Leaving mode KeyMode.command (reason: cmd accept)" should be logged Scenario: Edit a command and omit the start char - When I set up a fake editor returning "message-info foo" + When I setup a fake editor returning "message-info foo" And I run :edit-command Then the error "command must start with one of :/?" should be shown And "Leaving mode KeyMode.command *" should not be logged Scenario: Edit a command to be empty When I run :set-cmd-text : - When I set up a fake editor returning empty text + When I setup a fake editor returning empty text And I run :edit-command Then the error "command must start with one of :/?" should be shown And "Leaving mode KeyMode.command *" should not be logged @@ -194,13 +194,20 @@ Feature: Opening external editors ## select single file Scenario: Select one file with single command - When I set up a fake "single_file" fileselector selecting "tests/end2end/data/numbers/1.txt" + When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file + And I open data/fileselect.html + And I run :click-element id single_file + Then the javascript message "Files: 1.txt" should be logged + + Scenario: Select one file with single command that writes to stdout + When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to stdout And I open data/fileselect.html And I run :click-element id single_file Then the javascript message "Files: 1.txt" should be logged Scenario: Select two files with single command - When I set up a fake "single_file" fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" + When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" and writes to a temporary file + And I open data/fileselect.html And I run :click-element id single_file Then the javascript message "Files: 1.txt" should be logged @@ -209,13 +216,15 @@ Feature: Opening external editors ## select multiple files Scenario: Select one file with multiple command - When I set up a fake "multiple_files" fileselector selecting "tests/end2end/data/numbers/1.txt" + When I setup a fake multiple_files fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file + And I open data/fileselect.html And I run :click-element id multiple_files Then the javascript message "Files: 1.txt" should be logged Scenario: Select two files with multiple command - When I set up a fake "multiple_files" fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" + When I setup a fake multiple_files fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" and writes to a temporary file + And I open data/fileselect.html And I run :click-element id multiple_files Then the javascript message "Files: 1.txt, 2.txt" should be logged diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature index 0f0c015e0..e48947cbd 100644 --- a/tests/end2end/features/sessions.feature +++ b/tests/end2end/features/sessions.feature @@ -395,9 +395,10 @@ Feature: Saving and loading sessions And I run :session-load -c pin_session And I wait until data/numbers/3.txt is loaded And I run :tab-focus 2 - And I run :open hello world - Then the message "Tab is pinned!" should be shown + And I open data/numbers/4.txt + Then the message "Tab is pinned! Opening in new tab." should be shown And the following tabs should be open: - data/numbers/1.txt - data/numbers/2.txt (active) (pinned) + - data/numbers/4.txt - data/numbers/3.txt diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index ca0efefc4..7db054573 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -1534,10 +1534,11 @@ Feature: Tab management Scenario: :tab-pin open url When I open data/numbers/1.txt And I run :tab-pin - And I open data/numbers/2.txt without waiting - Then the message "Tab is pinned!" should be shown + And I open data/numbers/2.txt + Then the message "Tab is pinned! Opening in new tab." should be shown And the following tabs should be open: - data/numbers/1.txt (active) (pinned) + - data/numbers/2.txt Scenario: :tab-pin open url with tabs.pinned.frozen = false When I set tabs.pinned.frozen to false diff --git a/tests/end2end/features/test_editor_bdd.py b/tests/end2end/features/test_editor_bdd.py index 445691bee..40f77a0f7 100644 --- a/tests/end2end/features/test_editor_bdd.py +++ b/tests/end2end/features/test_editor_bdd.py @@ -32,7 +32,7 @@ bdd.scenarios('editor.feature') from qutebrowser.utils import utils -@bdd.when(bdd.parsers.parse('I set up a fake editor replacing "{text}" by ' +@bdd.when(bdd.parsers.parse('I setup a fake editor replacing "{text}" by ' '"{replacement}"')) def set_up_editor_replacement(quteproc, server, tmpdir, text, replacement): """Set up editor.command to a small python script doing a replacement.""" @@ -53,7 +53,7 @@ def set_up_editor_replacement(quteproc, server, tmpdir, text, replacement): quteproc.set_setting('editor.command', editor) -@bdd.when(bdd.parsers.parse('I set up a fake editor returning "{text}"')) +@bdd.when(bdd.parsers.parse('I setup a fake editor returning "{text}"')) def set_up_editor(quteproc, tmpdir, text): """Set up editor.command to a small python script inserting a text.""" script = tmpdir / 'script.py' @@ -67,7 +67,7 @@ def set_up_editor(quteproc, tmpdir, text): quteproc.set_setting('editor.command', editor) -@bdd.when(bdd.parsers.parse('I set up a fake editor returning empty text')) +@bdd.when(bdd.parsers.parse('I setup a fake editor returning empty text')) def set_up_editor_empty(quteproc, tmpdir): """Set up editor.command to a small python script inserting empty text.""" set_up_editor(quteproc, tmpdir, "") @@ -107,7 +107,7 @@ def editor_pid_watcher(tmpdir): return EditorPidWatcher(tmpdir) -@bdd.when(bdd.parsers.parse('I set up a fake editor that writes "{text}" on ' +@bdd.when(bdd.parsers.parse('I setup a fake editor that writes "{text}" on ' 'save')) def set_up_editor_wait(quteproc, tmpdir, text, editor_pid_watcher): """Set up editor.command to a small python script inserting a text.""" @@ -180,18 +180,31 @@ def save_editor_wait(tmpdir): os.kill(pid, signal.SIGUSR2) -@bdd.when(bdd.parsers.parse('I set up a fake "{kind}" fileselector ' - 'selecting "{files}"')) -def set_up_fileselector(quteproc, py_proc, kind, files): +@bdd.when(bdd.parsers.parse('I setup a fake {kind} fileselector ' + 'selecting "{files}" and writes to {output_type}')) +def set_up_fileselector(quteproc, py_proc, kind, files, output_type): """Set up fileselect.xxx.command to select the file(s).""" cmd, args = py_proc(r""" import os import sys - tmp_file = sys.argv[1] - with open(tmp_file, 'w') as f: - for selected_file in sys.argv[2:]: - f.write(os.path.abspath(selected_file) + "\n") + tmp_file = None + for i, arg in enumerate(sys.argv): + if arg.startswith('--file='): + tmp_file = arg[len('--file='):] + sys.argv.pop(i) + break + selected_files = sys.argv[1:] + if tmp_file is None: + for selected_file in selected_files: + print(os.path.abspath(selected_file)) + else: + with open(tmp_file, 'w') as f: + for selected_file in selected_files: + f.write(os.path.abspath(selected_file) + '\n') """) - fileselect_cmd = json.dumps([cmd, *args, '{}', *files.split(' ')]) + args += files.split(' ') + if output_type == "a temporary file": + args += ['--file={}'] + fileselect_cmd = json.dumps([cmd, *args]) quteproc.set_setting('fileselect.handler', 'external') quteproc.set_setting(f'fileselect.{kind}.command', fileselect_cmd) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 8a6b24557..22e9c6490 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -28,7 +28,7 @@ from datetime import datetime from unittest import mock import hypothesis -import hypothesis.strategies +import hypothesis.strategies as hst import pytest from PyQt5.QtCore import QUrl, QDateTime try: @@ -459,9 +459,10 @@ def test_filesystem_completion_model_interface(info, local_files_path): @hypothesis.given( - as_uri=hypothesis.strategies.booleans(), - add_sep=hypothesis.strategies.booleans(), - text=hypothesis.strategies.text(), + as_uri=hst.booleans(), + add_sep=hst.booleans(), + text=hst.text(alphabet=hst.characters( + blacklist_categories=['Cc'], blacklist_characters='\x00')), ) def test_filesystem_completion_hypothesis(info, as_uri, add_sep, text): if as_uri: @@ -1445,7 +1446,7 @@ def undo_completion_retains_sort_order(tabbed_browser_stubs, info): _check_completions(model, {"Closed tabs": expected}) -@hypothesis.given(text=hypothesis.strategies.text()) +@hypothesis.given(text=hst.text()) def test_listcategory_hypothesis(text): """Make sure we can't produce invalid patterns.""" cat = listcategory.ListCategory("test", []) diff --git a/tests/unit/misc/test_guiprocess.py b/tests/unit/misc/test_guiprocess.py index a2acad1ac..9e1b3916c 100644 --- a/tests/unit/misc/test_guiprocess.py +++ b/tests/unit/misc/test_guiprocess.py @@ -127,9 +127,11 @@ def test_start_output_message(proc, qtbot, caplog, message_mock, py_proc, if stdout_msg is not None: assert stdout_msg.level == usertypes.MessageLevel.info assert stdout_msg.text == 'stdout text' + assert proc.final_stdout.strip() == "stdout text", proc.final_stdout if stderr_msg is not None: assert stderr_msg.level == usertypes.MessageLevel.error assert stderr_msg.text == 'stderr text' + assert proc.final_stderr.strip() == "stderr text", proc.final_stderr def test_start_env(monkeypatch, qtbot, py_proc): |